From d3c46dc9cb99b1cdfe4afbf224951dc180d62d92 Mon Sep 17 00:00:00 2001 From: Lacustat Date: Tue, 27 Oct 2026 01:48:57 +0100 Subject: [PATCH] Freewood-OctoWoW-Codex 0.1.136 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016onJCSHLwhs9udhC9ZLzk9 --- Codex.lua | 4474 ++++++++++++++++++++++++++++++++++++ CodexAvail.lua | 581 +++++ CodexCatalog.lua | 799 +++++++ CodexFavUI.lua | 103 + CodexForever.lua | 196 ++ CodexHints.lua | 427 ++++ CodexLookup.lua | 328 +++ CodexMap.lua | 764 ++++++ CodexMapAPI.lua | 225 ++ CodexMapScan.lua | 304 +++ CodexNameUI.lua | 805 +++++++ CodexNames.lua | 494 ++++ CodexOwners.lua | 23 + CodexPath.lua | 1200 ++++++++++ CodexPathMap.lua | 556 +++++ CodexQuestProbe.lua | 215 ++ CodexRecipes.lua | 333 +++ CodexRoles.lua | 1289 +++++++++++ CodexSeed.lua | 708 ++++++ CodexShare.lua | 2314 +++++++++++++++++++ CodexShareUI.lua | 94 + CodexSpots.lua | 322 +++ CodexSync.lua | 1247 ++++++++++ CodexTaxi.lua | 505 ++++ CodexTaxiUI.lua | 149 ++ CodexTime.lua | 496 ++++ CodexTransport.lua | 230 ++ CodexTrust.lua | 308 +++ CodexUI.lua | 4415 +++++++++++++++++++++++++++++++++++ CodexWdbChain.lua | 1406 +++++++++++ CodexZones.lua | 237 ++ Freewood-OctoWoW-Codex.toc | 41 + LICENSE | 674 ++++++ Locales.lua | 1095 +++++++++ 34 files changed, 27357 insertions(+) create mode 100644 Codex.lua create mode 100644 CodexAvail.lua create mode 100644 CodexCatalog.lua create mode 100644 CodexFavUI.lua create mode 100644 CodexForever.lua create mode 100644 CodexHints.lua create mode 100644 CodexLookup.lua create mode 100644 CodexMap.lua create mode 100644 CodexMapAPI.lua create mode 100644 CodexMapScan.lua create mode 100644 CodexNameUI.lua create mode 100644 CodexNames.lua create mode 100644 CodexOwners.lua create mode 100644 CodexPath.lua create mode 100644 CodexPathMap.lua create mode 100644 CodexQuestProbe.lua create mode 100644 CodexRecipes.lua create mode 100644 CodexRoles.lua create mode 100644 CodexSeed.lua create mode 100644 CodexShare.lua create mode 100644 CodexShareUI.lua create mode 100644 CodexSpots.lua create mode 100644 CodexSync.lua create mode 100644 CodexTaxi.lua create mode 100644 CodexTaxiUI.lua create mode 100644 CodexTime.lua create mode 100644 CodexTransport.lua create mode 100644 CodexTrust.lua create mode 100644 CodexUI.lua create mode 100644 CodexWdbChain.lua create mode 100644 CodexZones.lua create mode 100644 Freewood-OctoWoW-Codex.toc create mode 100644 LICENSE create mode 100644 Locales.lua diff --git a/Codex.lua b/Codex.lua new file mode 100644 index 0000000..25bbf0e --- /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 = 10 -- 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 diff --git a/CodexForever.lua b/CodexForever.lua new file mode 100644 index 0000000..a5a1f66 --- /dev/null +++ b/CodexForever.lua @@ -0,0 +1,196 @@ +-- Freewood-OctoWoW-Codex: mitgelieferte Startdaten (Addon Freewood-OctoWoW-ForeverData) +-- +-- ForeverData enthaelt, was Spieler in Forever bereits erfasst haben: Quests mit Geber, Abgabe, Zielen, Belohnungen und Ketten, +-- Kreaturen, Objekte, Beute, Wegenetz, Verkehrsverbindungen, Ortspunkte. Die Daten bilden die unterste Schicht: +-- eigene Daten > empfangene Daten > ForeverData > Classic-Vorlage (Freewood-OctoWoW-CodexData) +-- Sie liegen nur im Speicher (nichts davon wird gespeichert oder weitergesendet) und gehen durch dieselbe Pruefung +-- (Share.Sanitize) wie empfangene Datensaetze. Abschalten: /fwforever aus + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Forever = { loaded = false, count = 0 } +Codex.Forever = Forever +NS.Forever = Forever + +local DATA_ADDON = "Freewood-OctoWoW-ForeverData" + +function Forever.Disabled() + local db = Codex.db + return db and db.settings and db.settings.noForever == true or false +end + +-- Zusatztabellen (Item-Qualitaet, Item-Werte, Anzeige-IDs, Namen, Rezepte): nur fehlende Eintraege werden ergaenzt, +-- eigene Werte gehen vor. Einmal je Datenstand (db.foreverRev). +local function FillTable(dst, src, check) + if type(dst) ~= "table" or type(src) ~= "table" then return end + for k, v in pairs(src) do + if dst[k] == nil and check(k, v) then dst[k] = v end + end +end + +local function IsId(k) return type(k) == "number" and k >= 1 and k == math.floor(k) and k < 1e9 end + +local EXTRAS = { + iq = function(k, v) return IsId(k) and type(v) == "number" and v >= 0 and v <= 8 end, + disp = function(k, v) return IsId(k) and type(v) == "number" and v > 0 end, + vfac = function(k, v) return IsId(k) and (v == "A" or v == "H") end, + wrq = function(k, v) return IsId(k) and type(v) == "table" end, + names = function(k, v) return IsId(k) and type(v) == "string" and #v <= 80 end, + istats = function(k, v) + if not IsId(k) or type(v) ~= "table" then return false end + for sk, sv in pairs(v) do + if sk == "_fx" then + if type(sv) ~= "table" then return false end + elseif type(sk) ~= "string" or #sk > 60 or (type(sv) ~= "number" and type(sv) ~= "string" and type(sv) ~= "boolean") then return false end + end + return true + end, +} + +local function MergeExtras(g) + local db = Codex.db + if not db or not g.extra or db.foreverRev == g.rev then return end + for name, check in pairs(EXTRAS) do + if type(g.extra[name]) == "table" then + db[name] = db[name] or {} + FillTable(db[name], g.extra[name], check) + end + end + if type(g.extra.recipes) == "table" then + db.recipes = db.recipes or {} + for prof, list in pairs(g.extra.recipes) do + if type(prof) == "string" and type(list) == "table" then + db.recipes[prof] = db.recipes[prof] or {} + FillTable(db.recipes[prof], list, function(k, v) return type(k) == "string" and #k <= 80 and type(v) == "table" end) + end + end + end + db.foreverRev = g.rev +end + +-- Daten des Addons laden (bei Bedarf wird das Addon geladen). Rueckgabe: true, wenn Daten da sind +function Forever.Load() + if Forever.loaded then return true end + if Forever.Disabled() or not Codex.Share or not Codex.Share.SetBuiltin then return false end + local g = _G.FreewoodForeverData + if not (g and g.ready) then + local load = (_G.C_AddOns and _G.C_AddOns.LoadAddOn) or _G.LoadAddOn + if type(load) == "function" then pcall(load, DATA_ADDON) end + g = _G.FreewoodForeverData + end + if not (g and g.ready and type(g.list) == "table") then + Forever.missing = true + return false + end + if Forever.loading then return false end + Forever.loading = true + local list = g.list + local total, slice, from = #list, 600, 1 + Codex.Share.BuiltinBegin() + Forever.count = 0 + local function Finish() + Codex.Share.BuiltinDone() + pcall(MergeExtras, g) + Forever.version, Forever.rev = g.version, g.rev + Forever.loaded = true + Forever.loading = false + Forever.missing = false + if Codex.chainRev then Codex.chainRev = Codex.chainRev + 1 end + FW:Fire("CODEX_CHANGED", "forever") + end + local function Step() + if not Forever.loading then return end -- zwischendurch ausgeschaltet + local to = math.min(total, from + slice - 1) + Forever.count = Forever.count + Codex.Share.BuiltinAdd(list, from, to) + from = to + 1 + if from > total then + Finish() + elseif _G.C_Timer and _G.C_Timer.After then + _G.C_Timer.After(0.05, Step) + else + Step() + end + end + Step() + return true +end + +-- Quellen eines Items aus den Startdaten: { u = { {id, chance} }, o = { ... }, v = { Haendler-IDs } } oder nil +local itemCache = {} +function Forever.Item(id) + if not Forever.loaded or Forever.Disabled() then return nil end + local g = _G.FreewoodForeverData + local s = g and type(g.src) == "table" and g.src[id] + if type(s) ~= "string" then return nil end + local c = itemCache[id] + if c then return c end + c = { u = {}, o = {}, v = {} } + for part in s:gmatch("[^;]+") do + local tag, body = part:sub(1, 1), part:sub(2) + if tag == "u" or tag == "o" then + for k, v in body:gmatch("(%d+)=([%d%.]+)") do + local t = c[tag] + t[#t + 1] = { id = tonumber(k), chance = tonumber(v) } + end + elseif tag == "v" then + for k in body:gmatch("%d+") do c.v[#c.v + 1] = tonumber(k) end + end + end + itemCache[id] = c + return c +end + +-- Alle Item-IDs mit Quellen in den Startdaten (Tabelle id -> Text; nur lesen) +function Forever.ItemIds() + if not Forever.loaded or Forever.Disabled() then return {} end + local g = _G.FreewoodForeverData + return (g and type(g.src) == "table") and g.src or {} +end + +function Forever.ItemHasVendor(id) + local it = Forever.Item(id) + return it ~= nil and #it.v > 0 +end + +-- Schalter: an = Startdaten nutzen (Standard) +function Forever.SetEnabled(on) + local db = Codex.db + if not db then return end + db.settings.noForever = (not on) or nil + if on then + Forever.Load() + elseif Forever.loading then + Forever.loading = false + Codex.Share.SetBuiltin({}) + Forever.count = 0 + elseif Forever.loaded then + Codex.Share.SetBuiltin({}) + Forever.loaded, Forever.count = false, 0 + if Codex.chainRev then Codex.chainRev = Codex.chainRev + 1 end + FW:Fire("CODEX_CHANGED", "forever") + end +end + +function Forever.Status() + local L = FW.L + if Forever.Disabled() then return L.CODEX_FOREVER_OFF end + if Forever.loaded then return string.format(L.CODEX_FOREVER_ON, Forever.count, tostring(Forever.version or "?")) end + return L.CODEX_FOREVER_MISSING +end + +SLASH_FREEWOODFOREVER1 = "/fwforever" +SlashCmdList["FREEWOODFOREVER"] = function(msg) + msg = (msg or ""):lower():gsub("^%s+", ""):gsub("%s+$", "") + if msg == "aus" or msg == "off" then + Forever.SetEnabled(false) + elseif msg == "an" or msg == "on" then + Forever.SetEnabled(true) + end + FW:Print(Forever.Status()) +end diff --git a/CodexHints.lua b/CodexHints.lua new file mode 100644 index 0000000..199d1cc --- /dev/null +++ b/CodexHints.lua @@ -0,0 +1,427 @@ +-- Freewood-OctoWoW-Codex: Lern-Hinweise +-- Nach einem Stufenaufstieg (ausserhalb des Kampfes) oder einer neuen Berufsstufe zeigt ein kurzer Hinweis +-- in der Bildschirmmitte, was man jetzt beim Lehrer lernen kann. Grundlage: Lernplan (Lehrer-Daten). + +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 Hints = { queue = {}, showing = false, shown = 0 } +NS.Hints = Hints +Codex.Hints = Hints + +local DURATION = 5 +local FADE = 0.6 +local MAX_ROWS = 5 +local ROW_H = 24 +local WIDTH = 380 + +------------------------------------------------------------------------ +-- Einstellungen +------------------------------------------------------------------------ + +function Hints.ClassOn() + return Codex.db.settings.hintClass ~= false +end +function Hints.ProfOn() + return Codex.db.settings.hintProf ~= false +end + +------------------------------------------------------------------------ +-- Inhalt +------------------------------------------------------------------------ + +local function SpellKnown(id) + if not id then return false end + local SB = _G.C_SpellBook + local fn = (SB and SB.IsSpellKnown) or _G.IsSpellKnown or (_G.C_Spell and _G.C_Spell.IsSpellKnown) + if type(fn) ~= "function" then return false end + local ok, known = pcall(fn, id) + return ok and known == true +end + +local function Entry(r) + local cost = Codex.ModePrice(r.pr) + return { nm = r.nm or "?", sp = r.sp, rk = r.rk or (r.sp and Codex.SpellRank(r.sp)) or nil, cost = cost } +end + +-- Fähigkeiten deiner Klasse, die auf dieser Stufe beim Lehrer dazukommen +function Hints.ClassEntries(level) + local token = Codex.Time and Codex.Time.PlayerToken() + if not token then return {} end + local plan = Codex.LearnPlan(token) + local g = plan.levels[level] + local list = {} + if g then + for _, sp in ipairs(g.spells) do + if not SpellKnown(sp.sp) then + list[#list + 1] = { nm = sp.nm or "?", sp = sp.sp, rk = sp.rk or (sp.sp and Codex.SpellRank(sp.sp)) or nil, cost = sp.cost } + end + end + end + return list +end + +-- Berufslehrer-Fähigkeiten, deren Fertigkeitswert in (von, bis] liegt +function Hints.ProfEntries(skill, from, to) + local list, seen = {}, {} + for _, u in pairs(Codex.V().units) do + if u.tr and u.ro and (u.ro.proftrainer or 0) > 0 then + for key, r in pairs(u.tr) do + if r.sn == skill and r.sr and r.sr > from and r.sr <= to then + local k = r.sp and ("s" .. r.sp) or key + if not seen[k] then + seen[k] = true + list[#list + 1] = Entry(r) + end + end + end + end + end + table.sort(list, function(a, b) + if a.nm ~= b.nm then return a.nm < b.nm end + return (a.rk or "") < (b.rk or "") + end) + return list +end + +------------------------------------------------------------------------ +-- Anzeige +------------------------------------------------------------------------ + +local frame + +-- gespeicherte Position (verschoben in den Einstellungen) oder Standard: Bildschirmmitte, etwas hoeher +local function ApplyPos(f) + f:ClearAllPoints() + local p = Codex.db.settings.hintPos + if type(p) == "table" and p.point and p.x and p.y then + f:SetPoint(p.point, UIParent, p.rel or p.point, p.x, p.y) + else + f:SetPoint("CENTER", UIParent, "CENTER", 0, 160) + end +end + +function Hints.SavePos() + if not frame or not frame.GetPoint then return false end + local point, _, rel, x, y = frame:GetPoint(1) + if not point then return false end + Codex.db.settings.hintPos = { point = point, rel = rel, x = x, y = y } + return true +end + +function Hints.ResetPos() + Codex.db.settings.hintPos = nil + if frame then ApplyPos(frame) end +end + +local function Build() + local f = CreateFrame("Button", nil, UIParent) + f:SetSize(WIDTH, 80) + f:SetFrameStrata("DIALOG") + f:SetClampedToScreen(true) + f:SetMovable(true) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", function(self) + if Hints.editing then self:StartMoving() end + end) + f:SetScript("OnDragStop", function(self) + self:StopMovingOrSizing() + if Hints.editing then Hints.SavePos() end + end) + ApplyPos(f) + f:Hide() + local bg = UI.CreatePanel(f, "bgDeep", "gold") + bg:SetAllPoints(f) + -- Inhalt auf eigener Ebene ueber dem Hintergrund (ein Kindfenster zeichnet sonst ueber den Texten des Eltern-Fensters) + local c = CreateFrame("Frame", nil, f) + c:SetAllPoints(f) + c:SetFrameLevel((bg:GetFrameLevel() or 1) + 2) + f.title = UI.CreateLabel(c, "", { font = "heading", size = 14, color = "goldLight" }) + f.title:SetPoint("TOPLEFT", f, "TOPLEFT", 12, -10) + f.title:SetPoint("TOPRIGHT", f, "TOPRIGHT", -12, -10) + f.title:SetWordWrap(false) + f.rows = {} + for i = 1, MAX_ROWS do + local row = {} + row.icon = c:CreateTexture(nil, "ARTWORK") + row.icon:SetSize(ROW_H - 4, ROW_H - 4) + row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + row.icon:SetPoint("TOPLEFT", f, "TOPLEFT", 12, -(34 + (i - 1) * ROW_H)) + row.text = UI.CreateLabel(c, "", { size = 12 }) + row.text:SetPoint("LEFT", row.icon, "RIGHT", 8, 0) + row.text:SetWidth(WIDTH - 150) + row.text:SetWordWrap(false) + row.cost = UI.CreateLabel(c, "", { size = 11, color = "textDim", justify = "RIGHT" }) + row.cost:SetPoint("RIGHT", f, "RIGHT", -12, 0) + row.cost:SetPoint("TOP", row.icon, "TOP", 0, 0) + row.cost:SetPoint("BOTTOM", row.icon, "BOTTOM", 0, 0) + row.cost:SetWidth(120) + f.rows[i] = row + end + f.more = UI.CreateLabel(c, "", { size = 11, color = "textDim" }) + f.foot = UI.CreateLabel(c, "", { size = 10, color = "textDim" }) + f:SetScript("OnClick", function() + if Hints.editing then return end + Hints.Dismiss() + if f.onClick then f.onClick() end + end) + f:SetScript("OnUpdate", function(self, elapsed) + if Hints.editing then + self.age = 0 + self:SetAlpha(1) + return + end + self.age = (self.age or 0) + (elapsed or 0) + if self.age >= DURATION then + Hints.Dismiss() + elseif self.age > DURATION - FADE then + self:SetAlpha(math.max(0, (DURATION - self.age) / FADE)) + end + end) + return f +end + +local function InCombat() + if _G.InCombatLockdown and _G.InCombatLockdown() then return true end + return _G.UnitAffectingCombat and _G.UnitAffectingCombat("player") or false +end + +local function Display(h) + frame = frame or Build() + ApplyPos(frame) + local L = FW.L + frame.title:SetText(h.title) + local n = math.min(#h.entries, MAX_ROWS) + local total = 0 + local money = _G.GetMoney and _G.GetMoney() or nil + for i = 1, MAX_ROWS do + local row, e = frame.rows[i], h.entries[i] + if e and i <= n then + local icon = e.sp and Codex.SpellIcon(e.sp) or nil + row.icon:SetTexture(icon or "Interface\\Icons\\INV_Misc_QuestionMark") + row.icon:Show() + row.text:SetText(e.nm .. (e.rk and (" |cff9a9aa3(" .. e.rk .. ")|r") or "")) + row.cost:SetText((e.cost and e.cost > 0 and Codex.FormatMoney) and Codex.FormatMoney(e.cost) or "") + else + row.icon:Hide() + row.text:SetText("") + row.cost:SetText("") + end + end + for _, e in ipairs(h.entries) do total = total + (e.cost or 0) end + local y = 34 + n * ROW_H + 4 + local more = #h.entries - n + frame.more:ClearAllPoints() + frame.more:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -y) + frame.more:SetText(more > 0 and string.format(L.CODEX_HINT_MORE, more) or "") + if more > 0 then y = y + 16 end + local foot = Hints.editing and L.CODEX_HINT_DRAG or L.CODEX_HINT_CLICK + if total > 0 and Codex.FormatMoney and not Hints.editing then + foot = string.format(L.CODEX_HINT_COST, Codex.FormatMoney(total)) + if money and money < total then + foot = foot .. " | " .. string.format(L.CODEX_HINT_MISSING, Codex.FormatMoney(total - money)) + end + foot = foot .. " | " .. L.CODEX_HINT_CLICK + end + frame.foot:ClearAllPoints() + frame.foot:SetPoint("TOPLEFT", frame, "TOPLEFT", 12, -y) + frame.foot:SetText(foot) + frame:SetHeight(y + 26) + frame.onClick = h.onClick + frame.age = 0 + frame:SetAlpha(1) + frame:Show() + Hints.showing = true + Hints.shown = Hints.shown + 1 + Hints.last = h +end + +function Hints.Dismiss() + if Hints.editing then return end + if frame then frame:Hide() end + Hints.showing = false + Hints.Flush() +end + +-- naechsten wartenden Hinweis zeigen, wenn kein Kampf ist und keiner laeuft +function Hints.Flush() + if Hints.showing or Hints.editing or InCombat() then return end + local h = table.remove(Hints.queue, 1) + if h then Display(h) end +end + +function Hints.Push(h) + if not h or #h.entries == 0 then return false end + Hints.queue[#Hints.queue + 1] = h + Hints.Flush() + return true +end + +local function OpenPlan(level) + return function() + UI:Show("codex") + if Codex.ui and Codex.ui.Go then Codex.ui.Go("learn", level) end + end +end + +function Hints.OnLevelUp(level) + level = tonumber(level) + if not level or not Hints.ClassOn() or not Codex.db.settings.enabled then return false end + local entries = Hints.ClassEntries(level) + return Hints.Push({ title = string.format(FW.L.CODEX_HINT_CLASS_TITLE, level), entries = entries, onClick = OpenPlan(level) }) +end + +local prevSkill = {} +function Hints.OnSkill(skill, value) + value = tonumber(value) + if not skill or not value then return false end + local from = prevSkill[skill] or (value - 1) + prevSkill[skill] = value + if not Hints.ProfOn() or not Codex.db.settings.enabled then return false end + local entries = Hints.ProfEntries(skill, from, value) + return Hints.Push({ title = string.format(FW.L.CODEX_HINT_PROF_TITLE, skill, value), entries = entries, onClick = function() UI:Show("codex") end }) +end + +-- Test aus den Einstellungen: letzte Stufe mit Daten, sonst eine Beispielzeile +function Hints.Demo() + local pl = _G.UnitLevel and _G.UnitLevel("player") or 1 + local entries + for lv = pl, 1, -1 do + entries = Hints.ClassEntries(lv) + if #entries > 0 then + pl = lv + break + end + end + if not entries or #entries == 0 then + entries = { { nm = FW.L.CODEX_HINT_DEMO, rk = "1", cost = 10000 } } + end + Hints.queue = {} + Hints.showing = false + if frame then frame:Hide() end + Hints.Push({ title = string.format(FW.L.CODEX_HINT_CLASS_TITLE, pl), entries = entries, onClick = OpenPlan(pl) }) +end + +-- Verschieben: der Hinweis bleibt stehen, bis der Modus beendet wird; Position wird beim Loslassen gespeichert +function Hints.SetEditMode(on) + on = on and true or false + if on == (Hints.editing == true) then return end + if on then + Hints.queue, Hints.showing = {}, false + if frame then frame:Hide() end + local pl = _G.UnitLevel and _G.UnitLevel("player") or 1 + local entries = { { nm = FW.L.CODEX_HINT_DEMO, rk = "1", cost = 10000 }, { nm = FW.L.CODEX_HINT_DEMO, rk = "2", cost = 25000 } } + Hints.editing = true + Display({ title = string.format(FW.L.CODEX_HINT_CLASS_TITLE, pl), entries = entries }) + else + Hints.SavePos() + Hints.editing = false + Hints.showing = false + if frame then frame:Hide() end + Hints.Flush() + end + FW:Fire("CODEX_HINT_EDIT") +end + +------------------------------------------------------------------------ +-- Ereignisse +------------------------------------------------------------------------ + +-- Muster aus dem Textbaustein des Spiels ("... %s ... %d" oder mit Stellen "%1$s ... %2$d"). Rueckgabe: Muster, Reihenfolge +-- der Platzhalter (Stelle -> Rang in der Fundstelle), sonst nil +local function SkillPattern(fmt) + fmt = fmt or _G.SKILL_RANK_UP + if type(fmt) ~= "string" then return nil end + local out, order, i = { "^" }, {}, 1 + while i <= #fmt do + local pos, kind, len = fmt:match("^%%(%d+)%$([sd])()", i) + if pos then + i = len + else + kind, len = fmt:match("^%%([sd])()", i) + pos = #order + 1 + if kind then i = len end + end + if kind then + out[#out + 1] = kind == "s" and "(.+)" or "(%d+)" + order[#order + 1] = tonumber(pos) + else + local ch = fmt:sub(i, i) + out[#out + 1] = ch:match("%w") and ch or ("%" .. ch) + i = i + 1 + end + end + out[#out + 1] = "$" + return table.concat(out), order +end +Hints.SkillPattern = SkillPattern + +FW:RegisterEvent("PLAYER_LEVEL_UP", function(_, level) + if C_Timer and C_Timer.After then + C_Timer.After(1, function() Hints.OnLevelUp(level) end) + else + Hints.OnLevelUp(level) + end +end, Hints) +FW:RegisterEvent("PLAYER_REGEN_ENABLED", function() Hints.Flush() end, Hints) +FW:RegisterEvent("PLAYER_REGEN_DISABLED", function() + if Hints.editing then Hints.SetEditMode(false) end +end, Hints) +FW:RegisterEvent("CHAT_MSG_SKILL", function(_, text) + local pat, order = SkillPattern() + if type(text) ~= "string" or not pat then return end + local caps = { text:match(pat) } + if #caps == 0 then return end + local skill, value = caps[1], caps[2] + if order and order[1] == 2 then skill, value = caps[2], caps[1] end -- Fertigkeit steht an Stelle 2 + if skill then Hints.OnSkill(skill, value) end +end, Hints) + +------------------------------------------------------------------------ +-- Einstellungen: Abschnitt +------------------------------------------------------------------------ + +if UI.RegisterSettingsSection then + UI:RegisterSettingsSection("codex-hints", { + order = 106, + height = 300, + build = function(parent) + local L = FW.L + local f, stack = UI.CreateSection(parent, L.CODEX_HINT_HEADER) + local note = UI.CreateLabel(f, L.CODEX_HINT_NOTE, { size = 11, color = "textDim", justifyV = "TOP" }) + stack:Add(note, { fit = true, gap = 8 }) + local classBox = UI.CreateCheckbox(f, L.CODEX_HINT_CLASS_SWITCH, function() return Hints.ClassOn() end, + function(on) Codex.db.settings.hintClass = on and true or false end) + stack:Add(classBox, { fit = true, gap = 10 }) + local profBox = UI.CreateCheckbox(f, L.CODEX_HINT_PROF_SWITCH, function() return Hints.ProfOn() end, + function(on) Codex.db.settings.hintProf = on and true or false end) + stack:Add(profBox, { fit = true, gap = 4 }) + local test = UI.CreateButton(f, L.CODEX_HINT_TEST, 200, 26, function() Hints.Demo() end, "default") + stack:Add(test, { height = 26, gap = 12 }) + local move + move = UI.CreateButton(f, L.CODEX_HINT_MOVE, 200, 26, function() + Hints.SetEditMode(not Hints.editing) + end, "default") + stack:Add(move, { height = 26, gap = 6 }) + local reset = UI.CreateButton(f, L.CODEX_HINT_RESET, 200, 26, function() Hints.ResetPos() end, "default") + stack:Add(reset, { height = 26, gap = 6 }) + stack:Layout() + f.classBox, f.profBox, f.move = classBox, profBox, move + FW:On("CODEX_HINT_EDIT", function() + move:SetLabel(Hints.editing and L.CODEX_HINT_MOVE_DONE or L.CODEX_HINT_MOVE) + move:SetStyle(Hints.editing and "primary" or "default") + end, f) + return f + end, + refresh = function(frame) + frame.classBox:Refresh() + frame.profBox:Refresh() + end, + }) +end diff --git a/CodexLookup.lua b/CodexLookup.lua new file mode 100644 index 0000000..cdffa3e --- /dev/null +++ b/CodexLookup.lua @@ -0,0 +1,328 @@ +-- Freewood-OctoWoW-Codex: Kreaturen ueber ihre ID nachschlagen (OctoWoW) +-- +-- C_CreatureInfo.GetCreatureInfoByID liefert zu einer Kreatur-ID: creatureID, displayID, name (englisch), type (Nummer), +-- family, rank, subName. Ist die Kreatur dem Client noch unbekannt, laedt RequestLoadCreatureByID sie nach +-- (Antwort: Ereignis CREATURE_DATA_LOAD_RESULT). +-- Im Hintergrund werden bei allen Kreaturen, die wir schon kennen, fehlende Angaben ergaenzt: +-- ct Typ (sprachunabhaengig, aus der Typ-Nummer) +-- c Klasse (elite, selten, Boss ...) falls noch keine bekannt +-- ne englischer Name (Ersatz, wenn kein gesehener Name da ist) +-- Es werden nur bestehende Datensaetze ergaenzt, keine neuen angelegt. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Lookup = { started = false } +NS.Lookup = Lookup +Codex.Lookup = Lookup + +local TYPE_CODE = { + [1] = "beast", [2] = "dragonkin", [3] = "demon", [4] = "elemental", [5] = "giant", [6] = "undead", + [7] = "humanoid", [8] = "critter", [9] = "mechanical", [10] = "other", [11] = "totem", [12] = "pet", [13] = "gascloud", +} +local RANK_CODE = { [1] = "elite", [2] = "rareelite", [3] = "worldboss", [4] = "rare" } + +local TICK = 0.4 -- Abstand zwischen zwei Schritten (Sekunden) +local WAIT = 1.6 -- so lange auf die Antwort einer Anfrage warten +local PER_TICK = 25 -- bereits bekannte Kreaturen je Schritt (nur lesen, kostet fast nichts) +local RETRY_AFTER = 7 * 86400 -- erfolglose IDs nach einer Woche wieder versuchen +local FIRE_EVERY = 40 -- Oberflaeche hoechstens alle so viele Aenderungen auffrischen + +local state = { + queue = {}, first = 1, last = 0, + pending = nil, pendingAt = 0, + changed = 0, lastFire = 0, generation = 0, + stats = { read = 0, requested = 0, filled = 0, failed = 0 }, +} +Lookup.state = state + +local function Now() + return GetTime and GetTime() or 0 +end + +local function Store() + local db = Codex.db + db.lookup = db.lookup or {} + db.lookup.fail = db.lookup.fail or {} + return db.lookup +end + +local function Enabled() + return Codex.db ~= nil and Codex.db.settings ~= nil and Codex.db.settings.lookup ~= false + and _G.C_CreatureInfo ~= nil and _G.C_CreatureInfo.GetCreatureInfoByID ~= nil +end + +local function ReadInfo(id) + local ok, info = pcall(_G.C_CreatureInfo.GetCreatureInfoByID, id) + if ok and type(info) == "table" and info.creatureID == id then + return info + end + return nil +end + +local function NeedsInfo(u) + return type(u) == "table" and (not u.ct or not u.ne) +end + +-- Schreibt die Antwort in den bestehenden Datensatz. Rueckgabe: true, wenn sich etwas geaendert hat +function Lookup.Apply(id, info) + local u = Codex.db.units[id] + if type(u) ~= "table" or type(info) ~= "table" then + return false + end + local changed = false + local code = TYPE_CODE[info.type] + if code and u.ct ~= code then + u.ct = code + changed = true + end + local rc = RANK_CODE[info.rank] + if rc and not u.c then + u.c = rc + changed = true + end + local name = info.name + if type(name) == "string" and name ~= "" and not u.ne then + u.ne = name:sub(1, 60) + changed = true + end + return changed +end + +local function Changed(id) + state.changed = state.changed + 1 + state.stats.filled = state.stats.filled + 1 + if state.changed - state.lastFire >= FIRE_EVERY then + state.lastFire = state.changed + FW:Fire("CODEX_CHANGED", "unit", id) + end +end + +-- Warteschlange (mit Zaehlern, nicht table.remove) +local function Push(id) + state.last = state.last + 1 + state.queue[state.last] = id +end + +local function Pop() + if state.first > state.last then + return nil + end + local id = state.queue[state.first] + state.queue[state.first] = nil + state.first = state.first + 1 + if state.first > state.last then + state.first, state.last = 1, 0 + end + return id +end + +function Lookup.QueueSize() + return state.last - state.first + 1 +end + +-- Alle bekannten Kreaturen, denen etwas fehlt, in die Schlange stellen +function Lookup.Scan() + if not Enabled() then + return 0 + end + local fail = Store().fail + local now = time and time() or 0 + local queued = {} + for i = state.first, state.last do + queued[state.queue[i]] = true + end + local ids = {} + for id, u in pairs(Codex.db.units) do + if type(id) == "number" and NeedsInfo(u) and not queued[id] then + local f = fail[id] + if not f or now - f > RETRY_AFTER then + ids[#ids + 1] = id + end + end + end + table.sort(ids) + for i = 1, #ids do + Push(ids[i]) + end + return #ids +end + +local function Finish(id, info) + state.pending = nil + if info then + if Lookup.Apply(id, info) then + Changed(id) + end + else + state.stats.failed = state.stats.failed + 1 + Store().fail[id] = time and time() or 1 + end +end + +function Lookup.OnLoadResult(_, id, ok) + id = tonumber(id) + if state.pending and id == state.pending then + Finish(id, ReadInfo(id)) + end +end + +function Lookup.Step() + if not Enabled() then + return + end + local now = Now() + if state.pending then + if now - state.pendingAt < WAIT then + return + end + -- keine Antwort: noch einmal lesen, sonst als erfolglos merken + Finish(state.pending, ReadInfo(state.pending)) + end + local n = 0 + while n < PER_TICK do + local id = Pop() + if not id then + return + end + local u = Codex.db.units[id] + if NeedsInfo(u) then + local info = ReadInfo(id) + if info then + state.stats.read = state.stats.read + 1 + if Lookup.Apply(id, info) then + Changed(id) + end + n = n + 1 + else + -- dem Client unbekannt: nachladen lassen und auf die Antwort warten + state.pending, state.pendingAt = id, now + state.stats.requested = state.stats.requested + 1 + local ok = pcall(_G.C_CreatureInfo.RequestLoadCreatureByID, id) + if not ok then + Finish(id, nil) + end + return + end + end + end +end + +function Lookup:Start() + if self.started then + return + end + self.started = true + state.generation = state.generation + 1 + local gen = state.generation + FW:RegisterEvent("CREATURE_DATA_LOAD_RESULT", Lookup.OnLoadResult, Lookup) + local function Loop(delay, fn) + C_Timer.After(delay, function() + if not Lookup.started or gen ~= state.generation then + return + end + fn() + Loop(delay, fn) + end) + end + Loop(TICK, Lookup.Step) + -- erste Runde kurz nach dem Einloggen, danach alle 10 Minuten fuer neu gesehene Kreaturen + C_Timer.After(20, function() + if Lookup.started and gen == state.generation then + Lookup.Scan() + end + end) + Loop(600, Lookup.Scan) +end + +function Lookup:Stop() + self.started = false + state.generation = state.generation + 1 + FW:UnregisterEvent("CREATURE_DATA_LOAD_RESULT", Lookup) +end + +-- /fwlookup Stand anzeigen +-- /fwlookup run sofort neu durchsuchen +-- /fwlookup retry auch fruehere Fehlschlaege noch einmal versuchen +-- /fwlookup id eine einzelne Kreatur nachschlagen und anzeigen +-- /fwlookup clean Reste ohne Daten anzeigen; "clean yes" entfernt sie +function Lookup.Command(msg) + msg = (msg or ""):lower() + if msg == "retry" then + Store().fail = {} + msg = "run" + end + if msg == "run" then + local n = Lookup.Scan() + FW:Print(string.format("Nachschlagen: %d Kreaturen vorgemerkt", n)) + return + end + if msg == "clean" or msg == "clean yes" then + -- Reste ohne Beobachtungen: Kreaturen, die der Client nicht kennt (Nachschlagen erfolglos), ohne Kills, Positionen, + -- Rollen, Beute oder sonstige Daten. Erst anzeigen, nur mit "clean yes" loeschen. + local Share = Codex.Share + local list = {} + for id in pairs(Store().fail) do + local u = Codex.db.units[id] + if type(u) == "table" and not (Share and Share.IsShareable and Share.IsShareable("u", u)) and not u.ed and not u.cde and not u.cen + and not u.ro and not u.d and not u.cells and (u.k or 0) == 0 then + list[#list + 1] = id + end + end + table.sort(list) + if #list == 0 then + FW:Print("Aufraeumen: keine Reste gefunden") + return + end + for _, id in ipairs(list) do + local u = Codex.db.units[id] + FW:Print(string.format(" %d %s", id, tostring(u.n or u.ne or "?"))) + end + if msg == "clean yes" then + for _, id in ipairs(list) do + Codex.db.units[id] = nil + Store().fail[id] = nil + end + FW:Fire("CODEX_CHANGED", "unit", list[1]) + FW:Print(string.format("Aufraeumen: %d Kreaturen entfernt", #list)) + else + FW:Print(string.format("Aufraeumen: %d Kreaturen ohne Daten. Zum Entfernen: /fwlookup clean yes", #list)) + end + return + end + local want = msg:match("^id%s+(%d+)$") + if want then + local id = tonumber(want) + local info = ReadInfo(id) + if info then + FW:Print(string.format("Kreatur %d: %s, Typ %s, Rang %s, Familie %s, Anzeige %s", id, tostring(info.name), + tostring(info.type), tostring(info.rank), tostring(info.family), tostring(info.displayID))) + else + FW:Print("Kreatur " .. id .. " ist dem Client noch unbekannt (/fwnpc " .. id .. " laedt sie nach)") + end + return + end + local total, withType, withName = 0, 0, 0 + for id, u in pairs(Codex.db.units) do + if type(id) == "number" then + total = total + 1 + if u.ct then withType = withType + 1 end + if u.ne or u.n then withName = withName + 1 end + end + end + local failed = 0 + for _ in pairs(Store().fail) do failed = failed + 1 end + local s = state.stats + FW:Print(string.format("Nachschlagen: %s | Kreaturen %d, mit Typ %d, mit Namen %d | gelesen %d, angefragt %d, ergaenzt %d, erfolglos %d (gemerkt %d) | Schlange %d", + Enabled() and "an" or "aus", total, withType, withName, s.read, s.requested, s.filled, s.failed, failed, Lookup.QueueSize())) +end + +if _G.SlashCmdList then + _G.SLASH_FWLOOKUP1 = "/fwlookup" + _G.SlashCmdList["FWLOOKUP"] = function(msg) Lookup.Command(msg) end +end diff --git a/CodexMap.lua b/CodexMap.lua new file mode 100644 index 0000000..4fa12cf --- /dev/null +++ b/CodexMap.lua @@ -0,0 +1,764 @@ +-- Freewood-OctoWoW-Codex: Fundorte auf der Weltkarte +-- Zeichnet Punkte mit weissem Rand ueber Blizzards Kartenflaeche: schwarz fuer Kreaturen, +-- rot fuer Objekte, blau fuer Quest-Geber und -Abgabe. Fundorte werden gebuendelt (BIN Zellen je Punkt). +-- Quelle sind die Zellen, die Codex beim Spielen erfasst hat (Raster 200 x 200 je Karte). + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS.Codex +if not FW or not Codex then + return +end + +local UI = FW.UI +local GRID = Codex.GRID +local MAX_DOTS = 600 +local BIN = 3 -- Zellen je Punkt (3 Zellen = 1,5 % der Kartenbreite) +local DOT_MIN = 9 -- Punktgroesse in Bildschirm-Pixeln (kleinster / groesster Punkt) +local DOT_MAX = 14 +local RIM = 1.5 -- weisser Rand in Pixeln +local LAYER_LEVEL = 1500 -- weit ueber Blizzards Kartensymbolen, damit nichts die Punkte verdeckt + +local COLORS = { + u = { 0, 0, 0 }, + o = { 0.9, 0.1, 0.1 }, + p = { 0.2, 0.55, 1 }, +} +local ORDER = { u = 1, o = 2, p = 3 } +local MASK = "Interface\\CharacterFrame\\TempPortraitAlphaMask" + +local Overlay = { points = nil, label = nil, hooked = false } +Codex.Map = Overlay + +local layer, dots, infoButton, StartHover +local segDash = {} -- [Abschnitt] = { sig, tex = {...}, n }: Striche je Abschnitt, damit nur der veraenderte neu gesetzt wird +local DASH_LEN, DASH_GAP, DASH_THICK = 8, 6, 3 -- Pixel +local MAX_DASHES = 900 +local atan2 = math.atan2 or function(y, x) return math.atan(y, x) end + +-- Runder Punkt: weisse Scheibe mit schwarzer/roter/blauer Scheibe darauf. Rund wird er durch eine +-- Maske aus dem Spiel; fehlt sie, bleiben es Quadrate. +local function MaskAvailable() + if Codex.db and Codex.db.settings.squareDots then + return false + end + if type(GetFileIDFromPath) == "function" then + return GetFileIDFromPath(MASK) ~= nil + end + return true +end + +local function NewDot(f) + local dot = {} + local useMask = MaskAvailable() + for _, name in ipairs({ "rim", "fill" }) do + local tex = f:CreateTexture(nil, name == "rim" and "ARTWORK" or "OVERLAY", nil, name == "rim" and 1 or 2) + tex:SetColorTexture(1, 1, 1, 1) + if useMask and f.CreateMaskTexture and tex.AddMaskTexture then + local ok, mask = pcall(f.CreateMaskTexture, f) + if ok and mask then + mask:SetTexture(MASK, "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE") + mask:SetAllPoints(tex) + tex:AddMaskTexture(mask) + tex.mask = mask + end + end + dot[name] = tex + end + return dot +end + +local function HideDot(dot) + dot.rim:Hide() + dot.fill:Hide() +end + +local API = Codex.MapAPI + +local function Canvas() + return API.Canvas() +end + +local function EnsureLayer() + if layer then + return layer + end + local canvas = Canvas() + if not canvas then + return nil + end + -- Die Ebene haengt am Kartenfenster selbst (nicht an der Kartenflaeche): dort ist sie sicher sichtbar. + -- Die Punkte werden an die Kartenflaeche geheftet und folgen ihr beim Verschieben und Zoomen. + local wm = _G.WorldMapFrame + if API.Classic() then + -- 1.12-Karte: Ebene direkt auf der Kartenflaeche, Anteile 0..1 gelten fuer die ganze Flaeche + layer = CreateFrame("Frame", nil, canvas) + layer:SetAllPoints(canvas) + layer:SetFrameLevel((canvas:GetFrameLevel() or 1) + 20) + if wm.GetFrameStrata then layer:SetFrameStrata(wm:GetFrameStrata()) end + else + layer = CreateFrame("Frame", nil, wm) + layer:SetAllPoints(wm.ScrollContainer or wm) + layer:SetFrameLevel((wm:GetFrameLevel() or 1) + LAYER_LEVEL) + if wm.GetFrameStrata then + layer:SetFrameStrata(wm:GetFrameStrata()) + end + end + if layer.SetClipsChildren then + layer:SetClipsChildren(true) + end + layer:SetScript("OnSizeChanged", function(_, w) + if w and w > 1 and Overlay.points then + Overlay:Refresh() + end + end) + dots = {} + StartHover(layer) + return layer +end + +local function EnsureInfoButton() + if infoButton then + return infoButton + end + local wm = _G.WorldMapFrame + if not wm then + return nil + end + infoButton = UI.CreateButton(wm, "", 300, 22, function() + Overlay:Clear() + end, "default") + if API.Classic() then + local canvas = Canvas() or wm + if wm.GetFrameStrata then infoButton:SetFrameStrata(wm:GetFrameStrata()) end + infoButton:SetFrameLevel((canvas:GetFrameLevel() or 1) + 40) + infoButton:SetPoint("TOPLEFT", canvas, "TOPLEFT", 12, -12) + else + infoButton:SetFrameStrata("HIGH") + infoButton:SetFrameLevel((wm:GetFrameLevel() or 1) + LAYER_LEVEL + 100) + local anchor = wm.ScrollContainer or wm + infoButton:SetPoint("TOPLEFT", anchor, "TOPLEFT", 12, -42) + end + infoButton:Hide() + return infoButton +end + +local function CurrentMapID() + return API.CurrentMapID(function() + local list, seen = {}, {} + for _, p in ipairs(Overlay.points or {}) do + if p.m and not seen[p.m] then seen[p.m] = true; list[#list + 1] = p.m end + end + return list + end) +end + +-- Faktor Kartenflaeche -> Bildschirm (aendert sich beim Zoomen) +function Overlay:CanvasScale() + local canvas = Canvas() + local s = 1 + if canvas and canvas.GetEffectiveScale and _G.UIParent then + local a, b = canvas:GetEffectiveScale(), _G.UIParent:GetEffectiveScale() + if type(a) == "number" and type(b) == "number" and b > 0 and a > 0 then + s = a / b + end + end + return s +end + +-- Skalierung der Punkte-Ebene relativ zur Oberflaeche +local function LayerScale() + local s = 1 + if layer and layer.GetEffectiveScale and _G.UIParent then + local a, b = layer:GetEffectiveScale(), _G.UIParent:GetEffectiveScale() + if type(a) == "number" and type(b) == "number" and b > 0 and a > 0 then + s = a / b + end + end + return s +end + +-- Punkte einer Karte zusammenfassen: je Klasse (u, o, p) und BIN x BIN Zellen ein Punkt +function Overlay:Bins(mapID) + local bins = {} + for _, p in ipairs(self.points or {}) do + if p.m == mapID then + local cls = p.pin and "p" or (p.k == "o" and "o" or "u") + local cx = math.min(GRID - 1, math.max(0, math.floor(p.x * GRID))) + local cy = math.min(GRID - 1, math.max(0, math.floor(p.y * GRID))) + local base = math.floor(cy / BIN) * 1000 + math.floor(cx / BIN) + local key = base .. cls + local b = bins[key] + if not b then + b = { sx = 0, sy = 0, n = 0, names = {}, cls = cls, base = base } + bins[key] = b + end + local w = p.n or 1 + b.sx, b.sy, b.n = b.sx + p.x * w, b.sy + p.y * w, b.n + w + if p.name then + b.names[p.name] = (b.names[p.name] or 0) + w + end + end + end + local list, maxN, perBase = {}, 1, {} + for _, b in pairs(bins) do + b.x, b.y = b.sx / b.n, b.sy / b.n + list[#list + 1] = b + perBase[b.base] = (perBase[b.base] or 0) + 1 + if b.n > maxN then + maxN = b.n + end + end + for _, b in ipairs(list) do + b.shared = perBase[b.base] > 1 + end + -- Reihenfolge: Kreaturen zuerst, Quest-Punkte zuletzt (oben) + table.sort(list, function(a, b) + if ORDER[a.cls] ~= ORDER[b.cls] then return ORDER[a.cls] < ORDER[b.cls] end + if a.y ~= b.y then return a.y < b.y end + return a.x < b.x + end) + return list, maxN +end + +-- Gestrichelte Linie durch die Wegpunkte (Luftlinie von Station zu Station). +-- Jeder Abschnitt merkt sich seine Lage: bleibt er gleich, werden die Striche nur wieder eingeblendet. +local function HideDashes(from) + for i = from or 1, #segDash do + local s = segDash[i] + for k = 1, s.n do s.tex[k]:Hide() end + s.n, s.sig = 0, nil + end +end + +local function DrawPath(f, canvas, path, width, height, ratio, layerScale) + local total = 0 + local len = DASH_LEN / layerScale + local step = (DASH_LEN + DASH_GAP) / layerScale + for i = 2, #path do + local s = segDash[i - 1] + if not s then s = { tex = {}, n = 0 }; segDash[i - 1] = s end + local x1, y1 = path[i - 1].x * width * ratio, -path[i - 1].y * height * ratio + local x2, y2 = path[i].x * width * ratio, -path[i].y * height * ratio + local sig = string.format("%.2f,%.2f,%.2f,%.2f,%.4f", x1, y1, x2, y2, layerScale) + if s.sig == sig then + for k = 1, s.n do s.tex[k]:Show() end + else + local dx, dy = x2 - x1, y2 - y1 + local dist = math.sqrt(dx * dx + dy * dy) + local count = 0 + if dist > 0 then + local ux, uy = dx / dist, dy / dist + local angle = atan2(dy, dx) + local t = len / 2 + while t <= dist and total + count < MAX_DASHES do + count = count + 1 + local tex = s.tex[count] + if not tex then + tex = f:CreateTexture(nil, "OVERLAY", nil, 0) + tex:SetColorTexture(1, 0.85, 0.2, 0.95) + s.tex[count] = tex + end + tex:ClearAllPoints() + tex:SetSize(len, DASH_THICK / layerScale) + tex:SetPoint("CENTER", canvas, "TOPLEFT", x1 + ux * t, y1 + uy * t) + if tex.SetRotation then tex:SetRotation(angle) end + tex:Show() + t = t + step + end + end + for k = count + 1, s.n do s.tex[k]:Hide() end + s.n, s.sig = count, sig + end + total = total + s.n + end + HideDashes(#path) -- Abschnitte, die es nicht mehr gibt + return total +end + +function Overlay:Refresh() + local f = EnsureLayer() + if not f then + return + end + for _, d in ipairs(dots) do + HideDot(d) + end + if not (self.path and #self.path > 1 and self.points) then + HideDashes() + end + f:Show() + local btn = EnsureInfoButton() + if not self.points or #self.points == 0 then + if btn then + btn:Hide() + end + return + end + if btn then + btn:SetLabel(string.format(FW.L.CODEX_MAP_LABEL, self.label or "")) + btn:SetWidth(math.max(200, (btn.label:GetStringWidth() or 200) + 24)) + btn:Show() + end + + local mapID = CurrentMapID() + if not mapID then + return + end + local canvas = Canvas() or f + local width, height = canvas:GetWidth() or 1, canvas:GetHeight() or 1 + if width < 2 or height < 2 then + if _G.C_Timer and not self.retrying then + self.retrying = true + _G.C_Timer.After(0.2, function() + Overlay.retrying = false + if Overlay.points then + Overlay:Refresh() + end + end) + end + return + end + + local bins, maxN = self:Bins(mapID) + self.bins = bins + local scale = self:CanvasScale() + local layerScale = LayerScale() + local ratio = scale / layerScale -- Kartenflaeche-Einheiten -> Einheiten der Punkte-Ebene + self.lastScale = scale + self.drawnMap = mapID + self.hoverR = (DOT_MAX / 2 + 3) / scale / width -- Trefferradius als Anteil der Kartenbreite + self.hoverRY = (DOT_MAX / 2 + 3) / scale / height + self.lastDraw = { mapID = mapID, dots = #bins } + if self.path and #self.path > 1 and self.pathMap == mapID then + self.lastDraw.dashes = DrawPath(f, canvas, self.path, width, height, ratio, layerScale) + else + HideDashes() + end + + for i, b in ipairs(bins) do + if i > MAX_DOTS then + break + end + local dot = dots[i] + if not dot then + dot = NewDot(f) + dots[i] = dot + end + local px = DOT_MIN + (DOT_MAX - DOT_MIN) * math.min(1, b.n / maxN) + local d = px / layerScale + local rim = (px + RIM * 2) / layerScale + local off, offY = 0, 0 + if b.shared then + -- Kreatur links, Objekt rechts, Quest-Punkt darueber: nebeneinander statt uebereinander + local step = (px * 0.7) / layerScale + if b.cls == "u" then off = -step elseif b.cls == "o" then off = step else offY = step end + end + local x, y = b.x * width * ratio + off, -b.y * height * ratio + offY + local c = COLORS[b.cls] + dot.rim:ClearAllPoints() + dot.rim:SetSize(rim, rim) + dot.rim:SetPoint("CENTER", canvas, "TOPLEFT", x, y) + dot.rim:SetVertexColor(1, 1, 1, 1) + dot.rim:Show() + dot.fill:ClearAllPoints() + dot.fill:SetSize(d, d) + dot.fill:SetPoint("CENTER", canvas, "TOPLEFT", x, y) + dot.fill:SetVertexColor(c[1], c[2], c[3], 1) + dot.fill:Show() + end +end + +-- Mouseover: Namen der Fundorte in der Naehe des Mauszeigers +local function MouseOverMap() + if API.Classic() then + return API.CursorNorm() + end + local wm = _G.WorldMapFrame + local sc = wm and wm.ScrollContainer + if type(sc) ~= "table" or type(sc.GetNormalizedCursorPosition) ~= "function" then + return nil + end + local focus + if GetMouseFoci then + focus = GetMouseFoci() + focus = focus and focus[1] + elseif GetMouseFocus then + focus = GetMouseFocus() + end + local f, ok = focus, (focus == nil) + local depth = 0 + while f and depth < 12 do + if f == sc then + ok = true + break + end + f = f.GetParent and f:GetParent() or nil + depth = depth + 1 + end + if not ok then + return nil + end + local x, y = sc:GetNormalizedCursorPosition() + if not x or x < 0 or x > 1 or y < 0 or y > 1 then + return nil + end + return x, y +end + +function Overlay:HideHover() + self.hoverKey = nil + if self.hoverShown then + self.hoverShown = nil + local tip = _G.GameTooltip + if tip:GetOwner() == _G.WorldMapFrame then + tip:Hide() + end + end +end + +function Overlay:UpdateHover() + if not self.points or not self.bins or not self.drawnMap then + return self:HideHover() + end + local x, y = MouseOverMap() + if not x then + return self:HideHover() + end + local key = math.floor(x * GRID) * 1000 + math.floor(y * GRID) + if key == self.hoverKey and self.hoverShown ~= nil then + return + end + self.hoverKey = key + local r, ry = self.hoverR or 0.02, self.hoverRY or 0.03 + local names = {} + local hit = false + for i, b in ipairs(self.bins) do + if i > MAX_DOTS then + break + end + if math.abs(b.x - x) <= r and math.abs(b.y - y) <= ry then + hit = true + for name, n in pairs(b.names) do + names[name] = (names[name] or 0) + n + end + end + end + if not hit then + return self:HideHover() + end + local list = {} + for name, n in pairs(names) do + list[#list + 1] = { name = name, n = n } + end + table.sort(list, function(a, b) + if a.n ~= b.n then return a.n > b.n end + return a.name < b.name + end) + local tip = _G.GameTooltip + tip:SetOwner(_G.WorldMapFrame, "ANCHOR_CURSOR") + tip:AddLine(FW.L.CODEX_MAP_HOVER, 0.94, 0.75, 0.36) + for i, e in ipairs(list) do + if i > 10 then + tip:AddLine(string.format(FW.L.CODEX_MAP_MORE, #list - 10), 0.8, 0.8, 0.8) + break + end + tip:AddLine(e.name, 0.85, 0.85, 0.85) + end + tip:Show() + self.hoverShown = true +end + +local hoverElapsed = 0 +StartHover = function(f) + f:SetScript("OnUpdate", function(_, elapsed) + hoverElapsed = hoverElapsed + elapsed + if hoverElapsed < 0.08 then + return + end + hoverElapsed = 0 + if Overlay.points then + local sig = API.Signature() + if sig ~= Overlay.lastSig then + Overlay.lastSig = sig + Overlay:Refresh() + end + local sc = Overlay:CanvasScale() + if Overlay.lastScale and math.abs(sc - Overlay.lastScale) > 0.01 then + Overlay:Refresh() + end + Overlay:UpdateHover() + end + end) +end + +function Overlay:Hook() + if self.hooked then + return true + end + local wm = _G.WorldMapFrame + if not wm or not Canvas() then + return false + end + self.hooked = true + if wm.OnMapChanged then + pcall(hooksecurefunc, wm, "OnMapChanged", function() + Overlay:Refresh() + end) + end + if wm.HookScript then + wm:HookScript("OnShow", function() + Overlay:Refresh() + end) + end + if API.Classic() and _G.hooksecurefunc and _G.SetMapZoom then + pcall(hooksecurefunc, "SetMapZoom", function() Overlay:Refresh() end) + end + return true +end + +-- Karte mit dem meisten Gewicht, bevorzugt die aktuelle, wenn sie Punkte hat +function Overlay:BestMap() + local weights = {} + for _, p in ipairs(self.points or {}) do + weights[p.m] = (weights[p.m] or 0) + (p.n or 1) + end + local current = CurrentMapID() + if current and weights[current] then + return current + end + local best, bestN + for m, n in pairs(weights) do + if not bestN or n > bestN or (n == bestN and m < best) then + best, bestN = m, n + end + end + return best +end + +-- Weltkarte oeffnen und auf das passende Gebiet stellen +function Overlay:Focus() + local wm = _G.WorldMapFrame + if not wm or not self.points then + return + end + local target = self:BestMap() + if not wm:IsShown() then + if type(ToggleWorldMap) == "function" then + pcall(ToggleWorldMap) + else + pcall(wm.Show, wm) + end + end + if target and target ~= CurrentMapID() then + API.SetMap(target) + end + self:Refresh() + return target +end + +-- points: Liste { m, x, y, n, name, pin }, label: Text fuers Knoepfchen auf der Karte +function Overlay:Show(points, label, path, pathMap) + if not points or #points == 0 then + FW:Print(FW.L.CODEX_MAP_NONE) + return false + end + self.route = nil -- eine andere Anzeige beendet die laufende Quest-Route + self.points = points + self.label = label + self.path = path + self.pathMap = pathMap + self:Hook() + self:Refresh() + self:Focus() + return true +end + +function Overlay:Clear() + self.route = nil + self:HideHover() + self.points = nil + self.label = nil + self.path = nil + self.pathMap = nil + self.bins = nil + self:Refresh() +end + +------------------------------------------------------------------------ +-- Laufende Quest-Route: folgt dir, solange die Karte offen ist +------------------------------------------------------------------------ + +local ROUTE_MOVE = 0.005 -- Bewegung (Anteil der Kartenbreite), ab der neu gerechnet wird +local ROUTE_ARRIVE = 0.015 -- so nah dran gilt die Station als erreicht +local ROUTE_TICK = 1 -- Sekunden zwischen zwei Pruefungen +local routeGen = 0 + +-- Status der Stationen der aktuellen Route; aendert er sich (Quest angenommen), wird neu gerechnet +local function RouteSignature(points) + local t = {} + for i, p in ipairs(points or {}) do + t[i] = Codex.QuestStatus(p.id) and "x" or "o" + end + return table.concat(t) +end + +local function PlayerLoc() + local loc = FW.Util.GetPlayerLocation() + if loc and loc.mapID and loc.x and loc.y then return loc end + return nil +end + +-- Reihenfolge und Linie neu rechnen (billig: die Positionen der Quests stehen schon in r.cands). +-- Rueckgabe: false, wenn keine Station mehr uebrig ist +function Overlay:RouteRecompute(first) + local r = self.route + if not r then return false end + local loc = PlayerLoc() + local ax, ay = Codex.PlayerPosOn(r.mapID, loc) + if ax then + for _, p in ipairs(r.points or {}) do + if p.m == r.mapID and (p.x - ax) ^ 2 + (p.y - ay) ^ 2 < ROUTE_ARRIVE ^ 2 then + r.skip[p.id] = true + end + end + end + -- nur Quests, die noch offen sind (nicht angenommen, nicht abgegeben) und nicht schon erreicht wurden + local live = {} + for _, c in ipairs(r.cands) do + if not r.skip[c.id] and Codex.QuestStatus(c.id) == nil then live[#live + 1] = c end + end + r.cands = live + local points, mapID, path = Codex.QuestRouteOrder(live, loc) + if #points == 0 then + -- nichts mehr uebrig: Ende; sonst nur nichts in der Naehe (anderes Gebiet): alte Anzeige behalten + if #live == 0 then self:Clear() end + r.last = loc and { m = loc.mapID, x = loc.x, y = loc.y } or nil + return false + end + local mapChanged = r.mapID ~= mapID + self.points, self.path, self.pathMap = points, path, mapID + self.label = string.format(FW.L.CODEX_ROUTE_LABEL, #points) + self.bins = nil + r.points, r.mapID, r.sig = points, mapID, RouteSignature(points) + r.last = loc and { m = loc.mapID, x = loc.x, y = loc.y } or nil + local wm = _G.WorldMapFrame + if mapChanged and not first and wm and wm.IsShown and wm:IsShown() then + API.SetMap(mapID) + end + self:Refresh() + return true +end + +-- Eine Pruefung: neu rechnen, wenn du dich bewegt hast oder sich eine Quest geaendert hat +function Overlay:RouteStep() + local r = self.route + if not r then return false end + local wm = _G.WorldMapFrame + if wm and wm.IsShown and not wm:IsShown() then return false end + local loc = PlayerLoc() + local changed = RouteSignature(r.points) ~= r.sig + if not changed and loc then + local last = r.last + changed = not last or last.m ~= loc.mapID or (loc.x - last.x) ^ 2 + (loc.y - last.y) ^ 2 > ROUTE_MOVE ^ 2 + end + if changed then return self:RouteRecompute(false) end + return false +end + +local function RouteTick(gen) + local r = Overlay.route + if not r or r.gen ~= gen then return end + Overlay:RouteStep() + if Overlay.route and Overlay.route.gen == gen and _G.C_Timer then + _G.C_Timer.After(ROUTE_TICK, function() RouteTick(gen) end) + end +end + +-- ids: Quest-IDs der Liste. Zeigt die Route und haelt sie danach aktuell. +function Overlay:StartRoute(ids, opts) + local loc = PlayerLoc() + local cands = Codex.QuestRouteCandidates(ids, opts) + local points, mapID, path = Codex.QuestRouteOrder(cands, loc) + if #points == 0 then + FW:Print(FW.L.CODEX_ROUTE_NONE) + return false + end + self:Show(points, string.format(FW.L.CODEX_ROUTE_LABEL, #points), path, mapID) + routeGen = routeGen + 1 + self.route = { cands = cands, skip = {}, points = points, mapID = mapID, gen = routeGen, + sig = RouteSignature(points), last = loc and { m = loc.mapID, x = loc.x, y = loc.y } or nil } + if _G.C_Timer then + _G.C_Timer.After(ROUTE_TICK, function() RouteTick(routeGen) end) + end + return true +end + +FW:RegisterEvent("ADDON_LOADED", function(_, name) + if name == "Blizzard_WorldMap" then + Overlay:Hook() + Overlay:Refresh() + end +end, Overlay) + +-- Diagnose: /fwcodex map +function Overlay:Debug() + local canvas = Canvas() + local d = self.lastDraw + FW:Print("Karte: " .. tostring(CurrentMapID()) .. ", Flaeche " .. tostring(canvas and canvas:GetWidth()) .. " x " + .. tostring(canvas and canvas:GetHeight()) .. ", Ebene " .. tostring(canvas and canvas:GetFrameLevel()) + .. ", Overlay-Ebene " .. tostring(layer and layer:GetFrameLevel())) + FW:Print("Punkte geladen: " .. tostring(self.points and #self.points or 0) .. ", gezeichnet: " .. tostring(d and d.dots) + .. ", Maske: " .. tostring(MaskAvailable()) .. ", Skalierung: " .. string.format("%.2f", self:CanvasScale())) + local wm = _G.WorldMapFrame + FW:Print("Kartenfenster sichtbar: " .. tostring(wm and wm:IsShown()) .. ", Kartenflaeche sichtbar: " .. tostring(canvas and canvas:IsVisible()) + .. ", Punkte-Ebene sichtbar: " .. tostring(layer and layer:IsVisible()) .. ", Ebenen-Skalierung: " .. string.format("%.2f", LayerScale())) + API.Debug() + local seen = {} + for _, p in ipairs(self.points or {}) do + if p.m and not seen[p.m] then + seen[p.m] = true + local c, z = API.Resolve(p.m) + FW:Print("Punkte-Karte " .. p.m .. " (" .. tostring(_G.C_Map and _G.C_Map.GetMapInfo and (_G.C_Map.GetMapInfo(p.m) or {}).name) .. ") -> Kontinent " + .. tostring(c) .. ", Zone " .. tostring(z)) + end + end + local first = dots and dots[1] + if first then + FW:Print("Erster Punkt: sichtbar " .. tostring(first.fill:IsVisible()) .. ", Grösse " .. tostring(first.fill:GetWidth()) + .. ", Ebene der Ebene sichtbar " .. tostring(layer and layer:IsVisible())) + end +end + +-- Test: grosser roter Kasten in der Kartenmitte, zeigt ob ueberhaupt gezeichnet wird +function Overlay:Test() + local f = EnsureLayer() + if not f then + FW:Print("Weltkarte nicht gefunden.") + return + end + if not f.testTex then + f.testTex = f:CreateTexture(nil, "OVERLAY") + f.testTex:SetColorTexture(1, 0.1, 0.1, 0.6) + f.testTex:SetSize(160, 160) + f.testTex:SetPoint("CENTER", f, "CENTER", 0, 0) + end + if f.testTex:IsShown() then + f.testTex:Hide() + else + f.testTex:Show() + end +end + +-- /fwcodex map square: Punkte als Quadrate (falls die runde Maske Probleme macht) +function Overlay:ToggleSquare() + local st = Codex.db.settings + st.squareDots = not st.squareDots + for _, d in ipairs(dots or {}) do + HideDot(d) + end + dots = {} + FW:Print("Codex-Karte: " .. (st.squareDots and "Quadrate" or "runde Punkte")) + self:Refresh() +end diff --git a/CodexMapAPI.lua b/CodexMapAPI.lua new file mode 100644 index 0000000..ea83044 --- /dev/null +++ b/CodexMapAPI.lua @@ -0,0 +1,225 @@ +-- Freewood-OctoWoW-Codex: Kartenschnittstelle fuer die Weltkarte des 1.12-Clients (OctoWoW). +-- Die alte Karte kennt weder SetMapID noch GetMapID noch eine Kartenflaeche mit Zoom. Stattdessen: +-- Kartenflaeche = WorldMapDetailFrame (Anteile 0..1 von links oben), Zone umschalten per SetMapZoom(Kontinent, Zone). +-- Die Zuordnung Karten-ID (AreaTable) -> Kontinent/Zone laeuft ueber den Zonennamen (C_Map.GetMapInfo gegen GetMapZones) +-- und wird zusaetzlich beim Spielen gelernt (stehst du in einer Zone, merkt sich Codex ihre Nummer). +-- Auf Clients mit Retail-Karte (SetMapID/GetMapID) werden diese Funktionen direkt benutzt. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local API = {} +Codex.MapAPI = API + +local resolved = {} -- [mapID] = "c:z" oder false (nicht gefunden) +local zoneToID = {} -- ["c:z"] = mapID + +local function Learned() + local st = Codex.db and Codex.db.settings + if not st then return {} end + st.mapZones = st.mapZones or {} + return st.mapZones +end + +local function Norm(s) + if type(s) ~= "string" then return nil end + s = string.gsub(string.lower(s), "[^%w]", "") + if s == "" then return nil end + return s +end + +function API.Classic() + local wm = _G.WorldMapFrame + return not (wm and wm.GetMapID and wm.SetMapID) +end + +function API.Canvas() + local wm = _G.WorldMapFrame + if not wm then return nil end + if not API.Classic() then + if wm.GetCanvas then + local ok, c = pcall(wm.GetCanvas, wm) + if ok and c then return c end + end + return wm.ScrollContainer and wm.ScrollContainer.Child or nil + end + return _G.WorldMapDetailFrame or _G.WorldMapButton +end + +-- Kontinent/Zone der angezeigten Karte +function API.Current() + if not (_G.GetCurrentMapContinent and _G.GetCurrentMapZone) then return nil end + local c, z = _G.GetCurrentMapContinent(), _G.GetCurrentMapZone() + if type(c) ~= "number" or type(z) ~= "number" then return nil end + return c, z +end + +function API.Signature() + local c, z = API.Current() + return c and (c .. ":" .. z) or "?" +end + +local function MapName(id) + local CM = _G.C_Map + if CM and CM.GetMapInfo then + local ok, info = pcall(CM.GetMapInfo, id) + if ok and type(info) == "table" and type(info.name) == "string" then return info.name end + end + if _G.C_Map and _G.C_Map.GetAreaInfo then + local ok, name = pcall(_G.C_Map.GetAreaInfo, id) + if ok and type(name) == "string" then return name end + end + return nil +end + +-- Karten-ID -> Kontinent, Zone (Zone 0 = Kontinentkarte) +function API.Resolve(id) + if type(id) ~= "number" then return nil end + local key = Learned()[id] or resolved[id] + if key == nil then + key = false + local want = Norm(MapName(id)) + if want and _G.GetMapContinents and _G.GetMapZones then + local conts = { _G.GetMapContinents() } + for c = 1, #conts do + if Norm(conts[c]) == want then key = c .. ":0" break end + local zones = { _G.GetMapZones(c) } + for z = 1, #zones do + if Norm(zones[z]) == want then key = c .. ":" .. z break end + end + if key then break end + end + end + resolved[id] = key + end + if not key then return nil end + local _, _, c, z = string.find(key, "^(%d+):(%d+)$") + return tonumber(c), tonumber(z) +end + +-- Karten-ID der angezeigten Karte. cands: Liste oder Funktion, die Karten-IDs liefert, unter denen gesucht wird. +function API.CurrentMapID(cands) + local wm = _G.WorldMapFrame + if not API.Classic() then + if wm and wm.GetMapID then + local ok, id = pcall(wm.GetMapID, wm) + if ok then return id end + end + return nil + end + local c, z = API.Current() + if not c then return nil end + local key = c .. ":" .. z + if zoneToID[key] then return zoneToID[key] end + for id, k in pairs(Learned()) do + if k == key then zoneToID[key] = id return id end + end + if type(cands) == "function" then cands = cands() end + local list = cands or {} + local bm = _G.C_Map and _G.C_Map.GetBestMapForUnit and select(2, pcall(_G.C_Map.GetBestMapForUnit, "player")) + if type(bm) == "number" then list[#list + 1] = bm end + for i = 1, #list do + local id = list[i] + local rc, rz = API.Resolve(id) + if rc == c and rz == z then + zoneToID[key] = id + return id + end + end + return nil +end + +-- Blizzards Kartenfenster (Titel, Auswahlfelder) nachziehen, falls es die Umschaltung nicht selbst tut +function API.RefreshWindow(c, z) + local names = { "WorldMapFrame_Update", "WorldMapContinentDropDown_Update", "WorldMapZoneDropDown_Update", "WorldMapZoomOutButton_Update" } + for i = 1, #names do + local f = _G[names[i]] + if type(f) == "function" then pcall(f) end + end + local title = _G.WorldMapFrameTitle or _G.WorldMapFrameTitleText + if title and title.SetText and _G.GetMapZones and _G.GetMapContinents then + local name + if z == 0 then + local conts = { _G.GetMapContinents() } + name = conts[c] + else + local zones = { _G.GetMapZones(c) } + name = zones[z] + end + if name then pcall(title.SetText, title, name) end + end +end + +function API.SetMap(id) + local wm = _G.WorldMapFrame + if not API.Classic() then + if wm and wm.SetMapID then pcall(wm.SetMapID, wm, id) return true end + return false + end + local c, z = API.Resolve(id) + if not c or not _G.SetMapZoom then return false end + if z == 0 then pcall(_G.SetMapZoom, c) else pcall(_G.SetMapZoom, c, z) end + API.RefreshWindow(c, z) + if _G.C_Timer and _G.C_Timer.After then + _G.C_Timer.After(0.2, function() API.RefreshWindow(c, z) end) + end + return true +end + +-- Mauszeiger auf der Kartenflaeche als Anteil 0..1 (links oben), sonst nil +function API.CursorNorm() + local canvas = API.Canvas() + if not (canvas and canvas.IsVisible and canvas:IsVisible()) then return nil end + local l, t, w, h = canvas:GetLeft(), canvas:GetTop(), canvas:GetWidth(), canvas:GetHeight() + if not (l and t and w and h and w > 1 and h > 1) then return nil end + local cx, cy = _G.GetCursorPosition() + local s = canvas:GetEffectiveScale() + if not (cx and s and s > 0) then return nil end + local x, y = (cx / s - l) / w, (t - cy / s) / h + if x < 0 or x > 1 or y < 0 or y > 1 then return nil end + return x, y +end + +-- Zonen-Nummern lernen: Karte kurz auf die aktuelle Zone stellen, solange das Kartenfenster zu ist +local function LearnHere() + local wm = _G.WorldMapFrame + if not API.Classic() or (wm and wm:IsShown()) then return end + if not (_G.SetMapToCurrentZone and _G.C_Map and _G.C_Map.GetBestMapForUnit) then return end + local ok, id = pcall(_G.C_Map.GetBestMapForUnit, "player") + if not ok or type(id) ~= "number" then return end + pcall(_G.SetMapToCurrentZone) + local c, z = API.Current() + if c and z and z > 0 then + local L = Learned() + L[id] = c .. ":" .. z + zoneToID[c .. ":" .. z] = id + end +end + +local function LearnLater() + if _G.C_Timer and _G.C_Timer.After then + _G.C_Timer.After(1.5, LearnHere) + else + LearnHere() + end +end + +local f = CreateFrame("Frame") +f:RegisterEvent("PLAYER_ENTERING_WORLD") +f:RegisterEvent("ZONE_CHANGED_NEW_AREA") +f:SetScript("OnEvent", LearnLater) + +-- /fwcodex mapzones: zeigt die Zuordnung fuer die angezeigte Karte +function API.Debug() + local c, z = API.Current() + FW:Print("Karte: Kontinent " .. tostring(c) .. ", Zone " .. tostring(z) .. ", Flaeche " + .. tostring(API.Canvas() and API.Canvas():GetName()) .. ", klassisch: " .. tostring(API.Classic())) + local n = 0 + for id, k in pairs(Learned()) do n = n + 1 end + FW:Print("Gelernte Zonen: " .. n) +end diff --git a/CodexMapScan.lua b/CodexMapScan.lua new file mode 100644 index 0000000..c03cd1f --- /dev/null +++ b/CodexMapScan.lua @@ -0,0 +1,304 @@ +-- Freewood-OctoWoW-Codex: Karten-Scan (ClassicAPI C_Map, C_TaxiMap) +-- Liest lokal (ohne Serveranfragen) alle Karten, Gebiete, Gebiets-Trigger und Flugpunkte aus dem Client. +-- Ablage in db.cat: m = Karten, a = Gebiete, ma = Gebiete je Karte, t = Trigger je Karte, x = Flugpunkte, shape = Aufbau der Antworten (zur Pruefung) +-- m[id] = "Feld=Wert;...|Name" a[id] = "Name" (oder "Feld=Wert;...|Name") t[mapID] = { "Feld=Wert;...", ... } x[nodeID] = "Feld=Wert;...|Name" + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Scan = { } +Codex.MapScan = Scan +NS.MapScan = Scan + +local MAX_MAP = 2500 +local MAX_AREA = 6000 +local SLICE = 150 -- Eintraege je Schritt +local TICK = 0.05 + +local function Say(text) FW:Print("Karten-Scan: " .. text) 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.m = c.m or {} + c.a = c.a or {} + c.ma = c.ma or {} + c.t = c.t or {} + c.x = c.x or {} + c.shape = c.shape or {} + return c +end + +local function Clean(t) + t = tostring(t):gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|", "/"):gsub(";", ",") + return t +end + +-- Aufbau einer Antwort als Text (nur die erste Antwort je Funktion), damit man die Felder pruefen kann +local function Describe(v, depth) + depth = depth or 0 + local tv = type(v) + if tv ~= "table" then return tv .. (tv == "number" and ("=" .. tostring(v)) or (tv == "string" and ("=" .. v:sub(1, 20)) or "")) end + if depth >= 2 then return "table" end + local keys = {} + for k in pairs(v) do keys[#keys + 1] = k end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + local parts = {} + for i, k in ipairs(keys) do + if i > 24 then parts[#parts + 1] = "..." break end + parts[#parts + 1] = tostring(k) .. ":" .. Describe(v[k], depth + 1) + end + return "{" .. table.concat(parts, ", ") .. "}" +end + +local function Shape(name, v) + local c = Store() + if c and c.shape[name] == nil then c.shape[name] = Describe(v):sub(1, 700) end +end + +-- einfache Felder als "Feld=Wert;..." (Zahlen, Wahrheitswerte, Zahlenlisten, x/y-Paare) +local function Pack(info, skip) + local keys = {} + for k, v in pairs(info) do + local tv = type(v) + if type(k) == "string" and k ~= skip 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("%.4f", v)) + elseif type(v) == "boolean" then + parts[#parts + 1] = k .. "=" .. (v and "1" or "0") + else + if type(v.x) == "number" and type(v.y) == "number" then + parts[#parts + 1] = k .. "=" .. string.format("%.4f:%.4f", v.x, v.y) + else + local list = {} + for i = 1, 40 do + if type(v[i]) == "number" then list[#list + 1] = tostring(v[i]) else break end + end + if #list > 0 then parts[#parts + 1] = k .. "=" .. table.concat(list, ",") end + end + end + end + return table.concat(parts, ";") +end + +local function Name(info) + if type(info) ~= "table" then return nil end + local n = info.name or info.areaName or info.mapName + return type(n) == "string" and n ~= "" and n or nil +end + +local function Count(t) local n = 0 for _ in pairs(t or {}) do n = n + 1 end return n end + +-- Phasen: jede holt Eintraege in Scheiben +local phases = {} + +phases[1] = { label = "Karten", total = function() return MAX_MAP end, step = function(c, id) + local CM = _G.C_Map + if not (CM and CM.GetMapInfo) then return end + local ok, info = pcall(CM.GetMapInfo, id) + if ok and type(info) == "table" then + local nm = Name(info) + if nm then + Shape("GetMapInfo", info) + c.m[id] = Pack(info, "name") .. "|" .. Clean(nm) + end + end +end } + +phases[2] = { label = "Gebiete", total = function() return MAX_AREA end, step = function(c, id) + local CM = _G.C_Map + if not (CM and CM.GetAreaInfo) then return end + local ok, r = pcall(CM.GetAreaInfo, id) + if not ok then return end + if type(r) == "string" and r ~= "" then + Shape("GetAreaInfo", r) + c.a[id] = Clean(r) + elseif type(r) == "table" and Name(r) then + Shape("GetAreaInfo", r) + c.a[id] = Pack(r, "name") .. "|" .. Clean(Name(r)) + end +end } + +-- Karten-IDs fuer die Phasen 3 und 4 +local mapIDs +local function MapIDs(c) + if not mapIDs then + -- Karten aus der Kartenliste plus die Welt-/Instanzkarten (0 bis 700): Trigger und Flugpunkte haengen an der Welt-Karten-ID + local seen = {} + mapIDs = {} + for id = 0, 700 do seen[id] = true mapIDs[#mapIDs + 1] = id end + for id in pairs(c.m) do + if type(id) == "number" and not seen[id] then seen[id] = true mapIDs[#mapIDs + 1] = id end + end + table.sort(mapIDs) + end + return mapIDs +end + +phases[3] = { label = "Trigger", total = function() local c = Store() return #MapIDs(c) end, step = function(c, i) + local CM = _G.C_Map + local id = MapIDs(c)[i] + if not (CM and id) then return end + if CM.GetAreaTriggers then + local ok, list = pcall(CM.GetAreaTriggers, id) + if ok and type(list) == "table" and next(list) then + Shape("GetAreaTriggers", list) + local rows = {} + for _, t in pairs(list) do + if type(t) == "table" then + rows[#rows + 1] = Pack(t) + elseif type(t) == "number" then + rows[#rows + 1] = "id=" .. t + end + end + if #rows > 0 then c.t[id] = rows end + end + end + -- Gebiete je Karte (zwei moegliche Funktionen) + for _, fname in ipairs({ "GetMapAreaIDs", "GetAreas" }) do + local f = CM[fname] + if f and not Scan.maDone then + local ok, list = pcall(f, id) + if ok and type(list) == "table" and next(list) then + Shape(fname, list) + local ids = {} + for k, v in pairs(list) do + if type(v) == "number" then ids[#ids + 1] = v + elseif type(v) == "table" then + local a = v.areaID or v.id + if type(a) == "number" then ids[#ids + 1] = a end + elseif type(k) == "number" and type(v) == "string" then ids[#ids + 1] = k end + end + table.sort(ids) + if #ids > 0 then c.ma[0] = table.concat(ids, ",") Scan.maDone = true end + end + end + end +end } + +local function TakeNodes(c, list) + if type(list) ~= "table" then return end + for _, n in pairs(list) do + if type(n) == "table" and type(n.nodeID) == "number" then + Shape("TaxiNode", n) + c.x[n.nodeID] = Pack(n, "name") .. "|" .. Clean(Name(n) or "") + end + end +end + +phases[4] = { label = "Flugpunkte", total = function() local c = Store() return #MapIDs(c) + 1 end, step = function(c, i) + local T = _G.C_TaxiMap + if not T then return end + local ids = MapIDs(c) + if i == 1 and T.GetAllTaxiNodes then + local ok, list = pcall(T.GetAllTaxiNodes) + if ok then TakeNodes(c, list) end + end + local id = ids[i - 1] + if id and T.GetAllTaxiNodes then + local ok, list = pcall(T.GetAllTaxiNodes, id) + if ok then TakeNodes(c, list) end + end + if id and T.GetTaxiNodesForMap then + local ok, list = pcall(T.GetTaxiNodesForMap, id) + if ok then TakeNodes(c, list) end + end +end } + +Scan.state = nil + +function Scan.Counts() + local c = Codex.db and Codex.db.cat + if not c then return 0, 0, 0, 0 end + return Count(c.m), Count(c.a), Count(c.t), Count(c.x) +end + +function Scan.Running() return Scan.state ~= nil and not Scan.state.stop end + +local function Tick(st) + if Scan.state ~= st then return end + if st.stop then Scan.state = nil return end + local c = Store() + local ph = phases[st.phase] + if not (c and ph) then Scan.state = nil return end + local total = ph.total() + local last = math.min(st.i + SLICE - 1, total) + for i = st.i, last do + local ok = pcall(ph.step, c, i) + if not ok then st.errors = (st.errors or 0) + 1 end + end + st.i = last + 1 + st.total = total + if st.i > total then + Say(string.format("%s fertig.", ph.label)) + if st.phase == 1 then mapIDs = nil end + st.phase = st.phase + 1 + st.i = 1 + if not phases[st.phase] then + local m, a, t, x = Scan.Counts() + Say(string.format("fertig: %d Karten, %d Gebiete, Trigger auf %d Karten, %d Flugpunkte%s.", m, a, t, x, + st.errors and (" (" .. st.errors .. " Fehler)") or "")) + Scan.state = nil + if FW.Fire then FW:Fire("CODEX_CHANGED", "mapscan") end + return + end + end + _G.C_Timer.After(TICK, function() Tick(st) end) +end + +-- Rueckgabe: true | false, Grund +function Scan.Start() + if Scan.state then return false, "busy" end + local CM = _G.C_Map + if not (CM and CM.GetMapInfo) or not _G.C_Timer then return false, "api" end + if not Store() then return false, "db" end + mapIDs = nil + Scan.maDone = false + local st = { phase = 1, i = 1, total = MAX_MAP } + Scan.state = st + Tick(st) + return true +end + +function Scan.Stop() + if Scan.state then Scan.state.stop = true end +end + +function Scan.Label() + local st = Scan.state + if not st then return nil end + return phases[st.phase] and phases[st.phase].label or "", math.min(st.i, st.total or 1), st.total or 1 +end + +SLASH_FREEWOODMAPSCAN1 = "/fwkarte" +SlashCmdList["FREEWOODMAPSCAN"] = function(msg) + msg = (msg or ""):lower():gsub("^%s+", ""):gsub("%s+$", "") + if msg == "stop" then + if Scan.Running() then Scan.Stop() Say("wird angehalten.") else Say("es laeuft nichts.") end + return + elseif msg == "status" then + local m, a, t, x = Scan.Counts() + Say(string.format("%d Karten, %d Gebiete, Trigger auf %d Karten, %d Flugpunkte.", m, a, t, x)) + return + end + local ok, why = Scan.Start() + if ok then + Say("gestartet (Karten, Gebiete, Trigger, Flugpunkte; lokal, ohne Serveranfragen). Anhalten: /fwkarte stop.") + elseif why == "busy" then + Say("es laeuft schon ein Scan.") + else + Say("nicht moeglich: " .. tostring(why)) + end +end diff --git a/CodexNameUI.lua b/CodexNameUI.lua new file mode 100644 index 0000000..b66b717 --- /dev/null +++ b/CodexNameUI.lua @@ -0,0 +1,805 @@ +-- Freewood-OctoWoW-Codex: Namen von Hand eintragen (Deutsch / Englisch) +-- Zwei Fenster im Tab "Datenbank": +-- "Name bearbeiten" fuer den gewaehlten Eintrag (Kreatur, Objekt, Quest, Item), mit Vorschlaegen aus dem Kampflog +-- "Namen einfuegen" mehrere Zeilen "ID Name" auf einmal fuer die aktuelle Liste + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex or not FW.UI then + return +end + +local UI = FW.UI +local Util = FW.Util + +local NameUI = {} +NS.NameUI = NameUI +Codex.NameUI = NameUI + +local EDITABLE = { units = true, objects = true, quests = true, items = true } +local KIND_LABEL = { units = "CODEX_KIND_UNITS", objects = "CODEX_KIND_OBJECTS", quests = "CODEX_KIND_QUESTS", items = "CODEX_KIND_ITEMS" } +local MAX_SUGGEST = 6 + +local function MakeInput(parent, width) + local box = UI.CreatePanel(parent, "bgDeep", "bronze") + box:SetSize(width, 26) + 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(100) + 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) + box.edit = edit + return box +end + +-- Wowhead-Adresse fuer Nachschlagen (an einer Stelle, falls sich die Domain aendert) +local WOWHEAD_BASE = "https://www.wowhead.com/forever/" +local WOWHEAD_PATH = { units = "npc", objects = "object", quests = "quest", items = "item" } +function NameUI.LookupURL(kind, id, lang) + local path = WOWHEAD_PATH[kind] + if not path or not id then return nil end + return WOWHEAD_BASE .. (lang == "de" and "de/" or "") .. path .. "=" .. tostring(id) +end + +-- Schreibgeschuetztes Feld: Klick markiert alles, dann Strg+C +local function MakeLink(parent, width) + local box = MakeInput(parent, width) + box:SetHeight(22) + local edit = box.edit + edit:SetMaxLetters(0) + edit:SetTextColor(UI.Color("goldLight")) + local function Select(self) self:HighlightText() end + edit:SetScript("OnEditFocusGained", Select) + edit:SetScript("OnMouseUp", function(self) self:SetFocus(); self:HighlightText() end) + edit:SetScript("OnTextChanged", function(self, user) + if user and box.fwUrl and self:GetText() ~= box.fwUrl then + self:SetText(box.fwUrl) + self:HighlightText() + end + end) + function box.SetURL(url) + box.fwUrl = url or "" + edit:SetText(url or "") + edit:SetCursorPosition(0) + end + return box +end + +local function MakeWindow(parent, width, height) + local w = UI.CreatePanel(parent, "bg", "gold") + w:SetSize(width, height) + w:SetPoint("CENTER", parent, "CENTER", 0, 0) + w:SetFrameStrata("DIALOG") + w:EnableMouse(true) + w:Hide() + return w +end + +local function GameName(kind, id) + local v = Codex.V() + if kind == "units" then + local e = v.units[id] + return e and e.n + elseif kind == "objects" then + local e = v.objects[id] + return e and e.n + elseif kind == "quests" then + local q = v.quests[id] + return q and q.t + end + local info = Util.GetItem and Util.GetItem(id) + return (info and info.name) or Codex.db.names[id] +end + +------------------------------------------------------------------------ +-- Fenster "Name bearbeiten" +------------------------------------------------------------------------ + +local function BuildEdit(parent, onChanged) + local L = FW.L + local win = MakeWindow(parent, 480, 520) + local state = {} + + local title = UI.CreateLabel(win, "", { font = "heading", size = 14, color = "goldLight" }) + title:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -14) + local game = UI.CreateLabel(win, "", { size = 11, color = "textDim" }) + game:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -40) + game:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -40) + game:SetWordWrap(false) + + local deLabel = UI.CreateLabel(win, L.CODEX_NAME_DE, { size = 12 }) + deLabel:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -68) + local deBox = MakeInput(win, 448) + deBox:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -86) + local deLink = MakeLink(win, 448) + deLink:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -114) + local enLabel = UI.CreateLabel(win, L.CODEX_NAME_EN, { size = 12 }) + enLabel:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -146) + local enBox = MakeInput(win, 448) + enBox:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -164) + local enLink = MakeLink(win, 448) + enLink:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -192) + + local hint = UI.CreateLabel(win, "", { size = 11, color = "textDim", justifyV = "TOP" }) + hint:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -222) + hint:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -222) + hint:SetHeight(32) + hint:SetWordWrap(true) + + local sugTitle = UI.CreateLabel(win, L.CODEX_NAME_KILLS, { size = 12, color = "goldLight" }) + sugTitle:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -260) + local sugNone = UI.CreateLabel(win, L.CODEX_NAME_NOKILLS, { size = 11, color = "textDim", justifyV = "TOP" }) + sugNone:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -284) + sugNone:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -284) + sugNone:SetHeight(32) + sugNone:SetWordWrap(true) + + local function Fill(text) + local box = Codex.Lang() == "en" and enBox or deBox + box.edit:SetText(text or "") + box.edit:SetFocus() + end + local sugButtons = {} + for i = 1, MAX_SUGGEST do + local b = UI.CreateButton(win, "", 448, 24, function(self) + if self.fwText then Fill(self.fwText) end + end, "default") + b:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -284 - (i - 1) * 28) + b:Hide() + sugButtons[i] = b + end + + local function Save() + if state.kind and state.id then + Codex:SetCustom(state.kind, state.id, deBox.edit:GetText(), enBox.edit:GetText()) + if onChanged then onChanged() end + end + win:Hide() + end + local save = UI.CreateButton(win, L.CODEX_NAME_SAVE, 140, 28, Save, "primary") + save:SetPoint("BOTTOMLEFT", win, "BOTTOMLEFT", 16, 14) + local clear = UI.CreateButton(win, L.CODEX_NAME_CLEAR, 140, 28, function() + deBox.edit:SetText("") + enBox.edit:SetText("") + Save() + end, "default") + clear:SetPoint("LEFT", save, "RIGHT", 8, 0) + local cancel = UI.CreateButton(win, L.CODEX_NAME_CANCEL, 140, 28, function() win:Hide() end, "default") + cancel:SetPoint("LEFT", clear, "RIGHT", 8, 0) + + function win.Open(kind, id) + state.kind, state.id = kind, id + local label = L[KIND_LABEL[kind]] or kind + title:SetText(string.format("%s #%s", label, tostring(id))) + local gn = GameName(kind, id) + game:SetText(gn and string.format(L.CODEX_NAME_GAME, gn) or "") + local de, en = Codex:GetCustom(kind, id) + deBox.edit:SetText(de or "") + enBox.edit:SetText(en or "") + hint:SetText((kind == "items" and L.CODEX_NAME_HINT_LOCAL or L.CODEX_NAME_HINT) .. "\n" .. (L.CODEX_NAME_LINK or "")) + deLink.SetURL(NameUI.LookupURL(kind, id, "de")) + enLink.SetURL(NameUI.LookupURL(kind, id, "en")) + local names = {} + if kind == "units" and Codex.Names and Codex.Names.RecentKills then + for _, k in ipairs(Codex.Names:RecentKills(900, MAX_SUGGEST)) do + names[#names + 1] = k.name + if k.alt then names[#names + 1] = k.alt end + end + end + local showSug = kind == "units" + if showSug then sugTitle:Show() else sugTitle:Hide() end + if showSug and #names == 0 then sugNone:Show() else sugNone:Hide() end + for i, b in ipairs(sugButtons) do + local text = names[i] + if text then + b.fwText = text + b:SetLabel(text) + b:Show() + else + b.fwText = nil + b:Hide() + end + end + win.fwKey = kind .. ":" .. tostring(id) + win:Show() + win:Raise() + end + return win +end + +------------------------------------------------------------------------ +-- Fenster "Eigenschaften bearbeiten": Zone(n), Dungeon, Klasse, Stufen +-- (Blizzard liefert aus Instanzen keine Orte und oft keine Klasse; hier koennen Berechtigte sie nachtragen) +------------------------------------------------------------------------ + +local mapIndex + +-- Alle Karten des Spiels (Name -> ID), einmal gelesen +local function MapIndex() + if mapIndex then return mapIndex end + mapIndex = {} + if C_Map and C_Map.GetMapInfo then + for id = 1, 3000 do + local ok, info = pcall(C_Map.GetMapInfo, id) + if ok and type(info) == "table" and type(info.name) == "string" and info.name ~= "" then + mapIndex[#mapIndex + 1] = { id = id, name = info.name, lower = info.name:lower() } + end + end + end + return mapIndex +end + +-- Karten, deren Name den Text enthaelt (oder deren ID passt). Rueckgabe: Liste { id, name } +function NameUI.FindMaps(text, limit) + local out = {} + text = type(text) == "string" and text:lower():gsub("^%s+", ""):gsub("%s+$", "") or "" + if text == "" then return out end + local num = tonumber(text) + if num and num >= 1 and num == math.floor(num) then + out[1] = { id = num, name = Codex.MapName(num) } + end + -- eingebaute Instanzen (Dungeons, Schlachtfelder): negative ID + local ids = {} + for id in pairs(Codex.INSTANCES) do ids[#ids + 1] = id end + table.sort(ids) + for _, id in ipairs(ids) do + if #out >= (limit or 6) then break end + local b = Codex.INSTANCES[id] + if b[1]:lower():find(text, 1, true) or b[2]:lower():find(text, 1, true) then + out[#out + 1] = { id = -id, name = Codex.InstanceName(id) } + end + end + for _, m in ipairs(MapIndex()) do + if #out >= (limit or 6) then break end + if m.lower:find(text, 1, true) and m.id ~= num then + out[#out + 1] = { id = m.id, name = m.name } + end + end + return out +end + +local CLASS_CYCLE = { false, "normal", "elite", "rare", "rareelite", "worldboss" } + +local function ClassLabel(cl) + local L = FW.L + if not cl then return L.CODEX_PROPS_AUTO end + return L["CODEX_PROPS_CLASS_" .. cl] or cl +end + +local function BuildProps(parent, onChanged) + local L = FW.L + local win = MakeWindow(parent, 520, 650) + local state = { z = {} } + local MAXZ = Codex.MAX_ED_ZONES + + local title = UI.CreateLabel(win, "", { font = "heading", size = 14, color = "goldLight" }) + title:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -14) + local game = UI.CreateLabel(win, "", { size = 11, color = "textDim" }) + game:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -40) + game:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -40) + game:SetWordWrap(false) + + -- Zonen + local zoneTitle = UI.CreateLabel(win, L.CODEX_PROPS_ZONES, { size = 12, color = "goldLight" }) + zoneTitle:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -68) + local zoneRows = {} + for i = 1, MAXZ do local i = i + local lbl = UI.CreateLabel(win, "", { size = 12 }) + lbl:SetPoint("TOPLEFT", win, "TOPLEFT", 24, -92 - (i - 1) * 24) + lbl:SetWidth(400) + lbl:SetWordWrap(false) + local rm = UI.CreateButton(win, "x", 24, 20, function() + table.remove(state.z, i) + win.Refresh() + end, "default") + rm:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -90 - (i - 1) * 24) + zoneRows[i] = { lbl = lbl, rm = rm } + end + local zoneNone = UI.CreateLabel(win, L.CODEX_PROPS_ZONES_NONE, { size = 11, color = "textDim" }) + zoneNone:SetPoint("TOPLEFT", win, "TOPLEFT", 24, -92) + + local zoneInput = MakeInput(win, 300) + zoneInput:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -92 - MAXZ * 24 - 8) + local sugBtns = {} + local function AddZone(id) + if id and #state.z < MAXZ then + for _, z in ipairs(state.z) do if z == id then return end end + state.z[#state.z + 1] = id + end + zoneInput.edit:SetText("") + win.Refresh() + end + local function Suggest() + local found = NameUI.FindMaps(zoneInput.edit:GetText(), #sugBtns) + for i, b in ipairs(sugBtns) do + local m = found[i] + if m then + b.fwId = m.id + b:SetLabel(string.format("%s (#%d)", m.name, m.id < 0 and -m.id or m.id)) + b:Show() + else + b.fwId = nil + b:Hide() + end + end + end + zoneInput.edit:SetScript("OnTextChanged", function(_, user) if user then Suggest() end end) + local here = UI.CreateButton(win, L.CODEX_PROPS_HERE, 170, 26, function() + local inst = Codex.CurrentInstance and Codex.CurrentInstance() + if inst then + AddZone(-inst) + return + end + local m = C_Map and C_Map.GetBestMapForUnit and C_Map.GetBestMapForUnit("player") + if m then AddZone(m) end + end, "default") + here:SetPoint("LEFT", zoneInput, "RIGHT", 8, 0) + for i = 1, 5 do + local b = UI.CreateButton(win, "", 488, 22, function(self) + if self.fwId then AddZone(self.fwId) end + end, "default") + b:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -92 - MAXZ * 24 - 40 - (i - 1) * 24) + b:Hide() + sugBtns[i] = b + end + local zoneHint = UI.CreateLabel(win, L.CODEX_PROPS_ZONE_HINT, { size = 11, color = "textDim", justifyV = "TOP" }) + zoneHint:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -92 - MAXZ * 24 - 40 - 5 * 24 - 2) + zoneHint:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -92 - MAXZ * 24 - 40 - 5 * 24 - 2) + zoneHint:SetHeight(30) + zoneHint:SetWordWrap(true) + + -- Dungeon / Klasse / Stufen + local baseY = 92 + MAXZ * 24 + 40 + 5 * 24 + 36 + local dgLabel = UI.CreateLabel(win, L.CODEX_PROPS_DUNGEON, { size = 12 }) + dgLabel:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -baseY - 4) + local dgBtn = UI.CreateButton(win, "", 200, 24, function() + if state.dg == nil then state.dg = true elseif state.dg == true then state.dg = false else state.dg = nil end + win.Refresh() + end, "default") + dgBtn:SetPoint("TOPLEFT", win, "TOPLEFT", 190, -baseY) + local clLabel = UI.CreateLabel(win, L.CODEX_PROPS_CLASS, { size = 12 }) + clLabel:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -baseY - 36) + local clBtn = UI.CreateButton(win, "", 200, 24, function() + local idx = 1 + for i, c in ipairs(CLASS_CYCLE) do if c == (state.cl or false) then idx = i end end + state.cl = CLASS_CYCLE[idx % #CLASS_CYCLE + 1] or nil + if state.cl == false then state.cl = nil end + win.Refresh() + end, "default") + clBtn:SetPoint("TOPLEFT", win, "TOPLEFT", 190, -baseY - 32) + local lvLabel = UI.CreateLabel(win, L.CODEX_PROPS_LEVELS, { size = 12 }) + lvLabel:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -baseY - 72) + local l1Box = MakeInput(win, 70) + l1Box:SetPoint("TOPLEFT", win, "TOPLEFT", 190, -baseY - 68) + l1Box.edit:SetMaxLetters(3) + local lvDash = UI.CreateLabel(win, "-", { size = 12 }) + lvDash:SetPoint("LEFT", l1Box, "RIGHT", 6, 0) + local l2Box = MakeInput(win, 70) + l2Box:SetPoint("LEFT", l1Box, "RIGHT", 20, 0) + l2Box.edit:SetMaxLetters(3) + -- Quests: Vorquests (an der Stelle der Stufen) + local preLabel = UI.CreateLabel(win, L.CODEX_PROPS_PRE, { size = 12 }) + preLabel:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -baseY - 72) + local preBox = MakeInput(win, 300) + preBox:SetPoint("TOPLEFT", win, "TOPLEFT", 190, -baseY - 68) + preBox.edit:SetMaxLetters(60) + + -- Quests: Folgequests, davor, danach, Stufe, Questtext (Dev) + local QROW = { nx = 36, bf = 108, af = 140, ql = 172, tx = 204, si = 236 } + local qLabels, qBoxes = {}, {} + local function QField(key, label, width, letters, y) + local lbl = UI.CreateLabel(win, label, { size = 12 }) + lbl:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -baseY - y - 4) + local box = MakeInput(win, width) + box:SetPoint("TOPLEFT", win, "TOPLEFT", 190, -baseY - y) + box.edit:SetMaxLetters(letters) + qLabels[key], qBoxes[key] = lbl, box + end + QField("nx", L.CODEX_PROPS_NEXT, 300, 60, QROW.nx) + QField("bf", L.CODEX_PROPS_BEFORE, 300, 60, QROW.bf) + QField("af", L.CODEX_PROPS_AFTER, 300, 60, QROW.af) + QField("ql", L.CODEX_PROPS_QLEVEL, 70, 3, QROW.ql) + QField("tx", L.CODEX_PROPS_TEXT, 300, 600, QROW.tx) + QField("si", L.CODEX_PROPS_STARTITEM, 120, 9, QROW.si) + + -- Kreaturen und Objekte: Quest-Zuordnung von Hand (Ziel von Quests, laesst Quest-Items fallen) + local uLabels, uBoxes = {}, {} + local function UField(key, label, width, letters, y) + local lbl = UI.CreateLabel(win, label, { size = 12 }) + lbl:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -baseY - y - 4) + local box = MakeInput(win, width) + box:SetPoint("TOPLEFT", win, "TOPLEFT", 190, -baseY - y) + box.edit:SetMaxLetters(letters) + uLabels[key], uBoxes[key] = lbl, box + end + UField("qk", L.CODEX_PROPS_QK, 300, 80, 108) + UField("qd", L.CODEX_PROPS_QD, 300, 120, 140) + UField("qb", L.CODEX_PROPS_QB, 300, 80, 172) + QField("qr", L.CODEX_PROPS_QR, 300, 200, 268) + + local hint = UI.CreateLabel(win, L.CODEX_PROPS_HINT, { size = 11, color = "textDim", justifyV = "TOP" }) + hint:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -baseY - 104) + hint:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -baseY - 104) + hint:SetHeight(58) + hint:SetWordWrap(true) + + function win.Refresh() + for i = 1, MAXZ do + local row = zoneRows[i] + local id = state.z[i] + if id then + row.lbl:SetText(string.format("%s |cff9a978c#%d|r", Codex.MapName(id), id < 0 and -id or id)) + row.lbl:Show() + row.rm:Show() + else + row.lbl:Hide() + row.rm:Hide() + end + end + if #state.z == 0 then zoneNone:Show() else zoneNone:Hide() end + dgBtn:SetLabel(state.dg == nil and L.CODEX_PROPS_UNKNOWN or (state.dg and L.CODEX_PROPS_YES or L.CODEX_PROPS_NO)) + local isUnit = state.kind == "units" + local hasDungeon = state.kind ~= nil + if hasDungeon then dgLabel:Show(); dgBtn:Show() end + if isUnit then + clLabel:Show(); clBtn:Show(); lvLabel:Show(); l1Box:Show(); l2Box:Show(); lvDash:Show() + clBtn:SetLabel(ClassLabel(state.cl)) + else + clLabel:Hide(); clBtn:Hide(); lvLabel:Hide(); l1Box:Hide(); l2Box:Hide(); lvDash:Hide() + end + if state.kind == "quests" or state.kind == "items" then + preLabel:SetText(state.kind == "items" and L.CODEX_PROPS_KEY or L.CODEX_PROPS_PRE) + preLabel:Show(); preBox:Show() + else + preLabel:Hide(); preBox:Hide() + end + local isQuest = state.kind == "quests" + for key, lbl in pairs(qLabels) do + if isQuest then lbl:Show(); qBoxes[key]:Show() else lbl:Hide(); qBoxes[key]:Hide() end + end + local isEnt = state.kind == "units" or state.kind == "objects" + local isItem = state.kind == "items" + for key, lbl in pairs(uLabels) do + local show = isEnt or (isItem and key == "qb") + if show then lbl:Show(); uBoxes[key]:Show() else lbl:Hide(); uBoxes[key]:Hide() end + end + -- bei Items steht "Gehoert zu Quest" direkt unter dem Schluessel-Feld + local qbY = isItem and 108 or 172 + uLabels.qb:ClearAllPoints() + uLabels.qb:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -baseY - qbY - 4) + uBoxes.qb:ClearAllPoints() + uBoxes.qb:SetPoint("TOPLEFT", win, "TOPLEFT", 190, -baseY - qbY) + -- fuer Quests (und Kreaturen/Objekte mit Quest-Zeilen) ist das Fenster hoeher, der Hinweis rutscht nach unten + win:SetHeight(isQuest and (baseY + 430) or (isEnt and (baseY + 340) or (isItem and 700 or 650))) + hint:ClearAllPoints() + local hy = isQuest and (268 + 40) or (isEnt and 212 or (isItem and 148 or 104)) + hint:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -baseY - hy) + hint:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -baseY - hy) + end + + local function Collect() + local ed = { z = {}, dg = state.dg, cl = state.cl } + for i, z in ipairs(state.z) do ed.z[i] = z end + if state.kind == "units" then + ed.l1, ed.l2 = tonumber(l1Box.edit:GetText()), tonumber(l2Box.edit:GetText()) + elseif state.kind == "quests" then + local pre = {} + for n in (preBox.edit:GetText() or ""):gmatch("%d+") do pre[#pre + 1] = tonumber(n) end + if #pre > 0 then ed.pre = pre end + for _, key in ipairs({ "nx", "bf", "af" }) do + local list = {} + for n in (qBoxes[key].edit:GetText() or ""):gmatch("%d+") do list[#list + 1] = tonumber(n) end + if #list > 0 then ed[key] = list end + end + ed.ql = tonumber(qBoxes.ql.edit:GetText()) + local tx = qBoxes.tx.edit:GetText() + if tx and tx:find("%S") then ed.tx = tx end + ed.si = tonumber((qBoxes.si.edit:GetText() or ""):match("%d+")) + elseif state.kind == "items" then + ed.key = tonumber((preBox.edit:GetText() or ""):match("%d+")) + end + if state.kind == "units" or state.kind == "objects" then + local qk, qd = {}, {} + for n in (uBoxes.qk.edit:GetText() or ""):gmatch("%d+") do qk[#qk + 1] = tonumber(n) end + for q, it in (uBoxes.qd.edit:GetText() or ""):gmatch("(%d+)%s*[:/]%s*(%d+)") do qd[#qd + 1] = q .. ":" .. it end + if #qk > 0 then ed.qk = qk end + if #qd > 0 then ed.qd = qd end + end + if state.kind == "units" or state.kind == "objects" or state.kind == "items" then + local qb = {} + for n in (uBoxes.qb.edit:GetText() or ""):gmatch("%d+") do qb[#qb + 1] = tonumber(n) end + if #qb > 0 then ed.qb = qb end + end + if state.kind == "quests" then + local qr = {} + for k, n in (qBoxes.qr.edit:GetText() or ""):gmatch("([iuoIUO])%s*[:#]?%s*(%d+)") do qr[#qr + 1] = k:lower() .. ":" .. n end + if #qr > 0 then ed.qr = qr end + end + return ed + end + local function Save() + if state.kind and state.id then + Codex:SetEd(state.kind, state.id, Collect()) + if onChanged then onChanged() end + end + win:Hide() + end + local save = UI.CreateButton(win, L.CODEX_NAME_SAVE, 140, 28, Save, "primary") + save:SetPoint("BOTTOMLEFT", win, "BOTTOMLEFT", 16, 14) + local clear = UI.CreateButton(win, L.CODEX_NAME_CLEAR, 140, 28, function() + state.z, state.dg, state.cl = {}, nil, nil + l1Box.edit:SetText("") + l2Box.edit:SetText("") + preBox.edit:SetText("") + for _, b in pairs(qBoxes) do b.edit:SetText("") end + for _, b in pairs(uBoxes) do b.edit:SetText("") end + Save() + end, "default") + clear:SetPoint("LEFT", save, "RIGHT", 8, 0) + local cancel = UI.CreateButton(win, L.CODEX_NAME_CANCEL, 140, 28, function() win:Hide() end, "default") + cancel:SetPoint("LEFT", clear, "RIGHT", 8, 0) + + function win.Open(kind, id) + state.kind, state.id = kind, id + local ed = Codex:GetEd(kind, id) or {} + state.z = {} + for i, z in ipairs(ed.z or {}) do state.z[i] = z end + state.dg, state.cl = ed.dg, ed.cl + l1Box.edit:SetText(ed.l1 and tostring(ed.l1) or "") + l2Box.edit:SetText(ed.l2 and tostring(ed.l2) or "") + preBox.edit:SetText(kind == "items" and (ed.key and tostring(ed.key) or "") or (ed.pre and table.concat(ed.pre, ", ") or "")) + hint:SetText(kind == "quests" and (L.CODEX_PROPS_PRE_HINT .. "\n" .. L.CODEX_PROPS_HINT) + or (kind == "items" and (L.CODEX_PROPS_KEY_HINT .. "\n" .. L.CODEX_PROPS_HINT)) + or ((kind == "units" or kind == "objects") and (L.CODEX_PROPS_Q_HINT .. "\n" .. L.CODEX_PROPS_HINT)) or L.CODEX_PROPS_HINT) + if kind == "quests" then hint:SetText(L.CODEX_PROPS_QR_HINT .. "\n" .. L.CODEX_PROPS_PRE_HINT .. "\n" .. L.CODEX_PROPS_HINT) end + for _, key in ipairs({ "nx", "bf", "af" }) do + qBoxes[key].edit:SetText(ed[key] and table.concat(ed[key], ", ") or "") + end + qBoxes.ql.edit:SetText(ed.ql and tostring(ed.ql) or "") + qBoxes.tx.edit:SetText(ed.tx or "") + qBoxes.si.edit:SetText(ed.si and tostring(ed.si) or "") + uBoxes.qk.edit:SetText(ed.qk and table.concat(ed.qk, ", ") or "") + uBoxes.qd.edit:SetText(ed.qd and table.concat(ed.qd, ", ") or "") + uBoxes.qb.edit:SetText(ed.qb and table.concat(ed.qb, ", ") or "") + qBoxes.qr.edit:SetText(ed.qr and table.concat(ed.qr, ", ") or "") + zoneInput.edit:SetText("") + Suggest() + title:SetText(string.format("%s #%s", L[KIND_LABEL[kind]] or kind, tostring(id))) + local gn = GameName(kind, id) + game:SetText(gn and string.format(L.CODEX_NAME_GAME, gn) or "") + win.fwKey = kind .. ":" .. tostring(id) + win.Refresh() + win:Show() + win:Raise() + end + return win +end + +-- Stufenspanne eines Gebiets von Hand setzen (nur Berechtigte): geht wie alle Eigenschaften ueber die Vorschau im Dev-Reiter +local function BuildZoneProps(parent, onChanged) + local L = FW.L + local win = MakeWindow(parent, 420, 210) + local state = {} + local title = UI.CreateLabel(win, "", { font = "heading", size = 14, color = "goldLight" }) + title:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -14) + local lvLabel = UI.CreateLabel(win, L.CODEX_PROPS_LEVELS, { size = 12 }) + lvLabel:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -52) + local l1Box = MakeInput(win, 70) + l1Box:SetPoint("TOPLEFT", win, "TOPLEFT", 190, -48) + l1Box.edit:SetMaxLetters(3) + local dash = UI.CreateLabel(win, "-", { size = 12 }) + dash:SetPoint("LEFT", l1Box, "RIGHT", 6, 0) + local l2Box = MakeInput(win, 70) + l2Box:SetPoint("LEFT", l1Box, "RIGHT", 20, 0) + l2Box.edit:SetMaxLetters(3) + local hint = UI.CreateLabel(win, L.CODEX_ZONE_EDIT_HINT, { size = 11, color = "textDim", justifyV = "TOP" }) + hint:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -86) + hint:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -86) + hint:SetHeight(60) + hint:SetWordWrap(true) + local function Save(clear) + if state.key then + local ed + if not clear then ed = { l1 = tonumber(l1Box.edit:GetText()), l2 = tonumber(l2Box.edit:GetText()) } end + Codex:SetEd("zones", Codex.ZoneEdID(state.key), ed) + if onChanged then onChanged() end + end + win:Hide() + end + local save = UI.CreateButton(win, L.CODEX_NAME_SAVE, 120, 28, function() Save(false) end, "primary") + save:SetPoint("BOTTOMLEFT", win, "BOTTOMLEFT", 16, 14) + local clear = UI.CreateButton(win, L.CODEX_NAME_CLEAR, 120, 28, function() Save(true) end, "default") + clear:SetPoint("LEFT", save, "RIGHT", 8, 0) + local cancel = UI.CreateButton(win, L.CODEX_NAME_CANCEL, 120, 28, function() win:Hide() end, "default") + cancel:SetPoint("LEFT", clear, "RIGHT", 8, 0) + function win.Open(key) + state.key = key + local ed = Codex:GetEd("zones", Codex.ZoneEdID(key)) or {} + l1Box.edit:SetText(ed.l1 and tostring(ed.l1) or "") + l2Box.edit:SetText(ed.l2 and tostring(ed.l2) or "") + title:SetText(string.format("%s: %s", L.CODEX_KIND_ZONES, Codex.MapName(key))) + win.fwKey = "zones:" .. tostring(key) + win:Show() + win:Raise() + end + return win +end + +------------------------------------------------------------------------ +-- Fenster "Namen einfuegen" +------------------------------------------------------------------------ + +local function BuildImport(parent, onChanged) + local L = FW.L + local win = MakeWindow(parent, 560, 440) + local state = { lang = Codex.Lang() } + + local title = UI.CreateLabel(win, L.CODEX_NAME_IMPORT, { font = "heading", size = 14, color = "goldLight" }) + title:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -14) + local help = UI.CreateLabel(win, "", { size = 11, color = "textDim", justifyV = "TOP" }) + help:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -40) + help:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -40) + help:SetHeight(32) + help:SetWordWrap(true) + + local deButton, enButton + local function Sync() + deButton:SetStyle(state.lang == "de" and "primary" or "default") + enButton:SetStyle(state.lang == "en" and "primary" or "default") + end + deButton = UI.CreateButton(win, L.CODEX_NAME_DE, 140, 26, function() state.lang = "de"; Sync() end, "default") + deButton:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -78) + enButton = UI.CreateButton(win, L.CODEX_NAME_EN, 140, 26, function() state.lang = "en"; Sync() end, "default") + enButton:SetPoint("LEFT", deButton, "RIGHT", 8, 0) + + local area = UI.CreateTextArea(win, 528, 220) + area:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -112) + + local result = UI.CreateLabel(win, "", { size = 12, color = "goldLight" }) + result:SetPoint("TOPLEFT", win, "TOPLEFT", 16, -344) + result:SetPoint("TOPRIGHT", win, "TOPRIGHT", -16, -344) + + local go = UI.CreateButton(win, L.CODEX_NAME_IMPORT_GO, 140, 28, function() + local done, skipped = Codex:ImportNames(state.kind, state.lang, area:GetText()) + result:SetText(string.format(L.CODEX_NAME_IMPORT_RESULT, done, skipped)) + if onChanged then onChanged() end + end, "primary") + go:SetPoint("BOTTOMLEFT", win, "BOTTOMLEFT", 16, 14) + local close = UI.CreateButton(win, L.CODEX_NAME_CLOSE, 140, 28, function() win:Hide() end, "default") + close:SetPoint("LEFT", go, "RIGHT", 8, 0) + + function win.Open(kind) + state.kind = kind + state.lang = Codex.Lang() + Sync() + help:SetText(string.format(L.CODEX_NAME_IMPORT_HELP, L[KIND_LABEL[kind]] or kind)) + result:SetText("") + area:SetText("") + win:Show() + win:Raise() + end + return win +end + +------------------------------------------------------------------------ +-- Knoepfe im Tab +-- getSel() gibt art, auswahl zurueck; onChanged() aktualisiert Liste und Detail +------------------------------------------------------------------------ + +function NameUI.Attach(f, getSel, onChanged, origin) + local L = FW.L + local ctl = { height = 0 } + origin = origin or { x = 0, y = 0 } + local editWin, importWin, propsWin, zoneWin + function NameUI.OpenZone(key) + if not (Codex.Trust and Codex.Trust.CanEdit()) then return end + zoneWin = zoneWin or BuildZoneProps(f, onChanged) + zoneWin.Open(key) + end + + local editButton = UI.CreateButton(f, L.CODEX_NAME_EDIT, 150, 26, function() + local kind, id = getSel() + if EDITABLE[kind] and id then + editWin = editWin or BuildEdit(f, onChanged) + editWin.Open(kind, id) + end + end, "default") + editButton:Hide() + + local importButton = UI.CreateButton(f, L.CODEX_NAME_IMPORT, 150, 26, function() + local kind = getSel() + if EDITABLE[kind] then + importWin = importWin or BuildImport(f, onChanged) + importWin.Open(kind) + end + end, "default") + importButton:Hide() + + local propsButton = UI.CreateButton(f, L.CODEX_PROPS_BTN, 150, 26, function() + local kind, id = getSel() + if EDITABLE[kind] and id then + propsWin = propsWin or BuildProps(f, onChanged) + propsWin.Open(kind, id) + end + end, "default") + propsButton:Hide() + + -- "Fehlerhaft markieren" (nur Berechtigte): blendet den Datensatz bei allen Spielern aus + local function BadNow(kind, id) + local hook = Codex.DevHook + local staged = hook and hook.StagedBad and hook.StagedBad(kind, id) + if staged ~= nil then return staged end + return Codex:IsBad(kind, id) + end + local badButton = UI.CreateButton(f, L.CODEX_BAD_MARK, 170, 26, function() + local kind, id = getSel() + if kind and id and kind ~= "items" then + Codex:SetBad(kind, id, not BadNow(kind, id)) + if onChanged then onChanged() end + ctl.Update() + end + end, "default") + badButton:Hide() + + -- Die Knoepfe stehen in einer eigenen Reihe unter "Zurueck/Favorit", von links nach rechts; + -- ist der Platz zu knapp, laufen sie in die naechste Reihe. ctl.height ist die belegte Hoehe. + local strip = { propsButton, badButton, importButton, editButton } + function ctl.Layout() + local avail = (f:GetWidth() or 0) - origin.x + if avail < 240 then avail = 640 end + local x, y, any = 0, 0, false + for _, b in ipairs(strip) do + if b:IsShown() then + local w = b:GetWidth() or 150 + if x > 0 and x + w > avail then + x = 0 + y = y + 32 + end + b:ClearAllPoints() + b:SetPoint("TOPLEFT", f, "TOPLEFT", origin.x + x, -(origin.y + y)) + x = x + w + 8 + any = true + end + end + local h = any and (y + 32) or 0 + if h ~= ctl.height then + ctl.height = h + if origin.relayout then origin.relayout() end + end + end + ctl.strip = strip + + function ctl.Update() + local kind, id = getSel() + if EDITABLE[kind] and kind ~= "items" and id and Codex.Trust then + local bad = BadNow(kind, id) + if Codex.Trust.Can(bad and "unbad" or "bad") then + badButton:SetLabel(bad and L.CODEX_BAD_UNMARK or L.CODEX_BAD_MARK) + badButton:SetStyle(bad and "primary" or "default") + badButton:Show() + else + badButton:Hide() + end + else + badButton:Hide() + end + -- Namen bearbeiten: nur berechtigte Charaktere mit dem Modul Freewood-OctoWoW-Dev + local can = Codex.Trust ~= nil and Codex.Trust.CanEdit() + if can and EDITABLE[kind] and id then editButton:Show() else editButton:Hide() end + if can and EDITABLE[kind] then importButton:Show() else importButton:Hide() end + if can and EDITABLE[kind] and id then propsButton:Show() else propsButton:Hide() end + if propsWin and propsWin:IsShown() and propsWin.fwKey ~= (tostring(kind) .. ":" .. tostring(id)) then propsWin:Hide() end + if editWin and editWin:IsShown() and editWin.fwKey ~= (tostring(kind) .. ":" .. tostring(id)) then editWin:Hide() end + if importWin and importWin:IsShown() and not EDITABLE[kind] then importWin:Hide() end + ctl.Layout() + end + ctl.editButton, ctl.importButton, ctl.propsButton = editButton, importButton, propsButton + return ctl +end diff --git a/CodexNames.lua b/CodexNames.lua new file mode 100644 index 0000000..cd87589 --- /dev/null +++ b/CodexNames.lua @@ -0,0 +1,494 @@ +-- Freewood-OctoWoW-Codex: Namen von Kreaturen lernen +-- +-- Forever gibt Kreaturennamen im Kampf als "geheime Werte" zurueck. In Instanzen ist fast alles Kampf, +-- darum kannte der Codex dort nur die IDs. Hier werden Namen auf mehreren Wegen gesammelt: +-- target / mouse Ziel und Mouseover (auch Leichen und noch nicht bekannte Kreaturen) +-- scan ausserhalb des Kampfes alle paar Sekunden: Ziel, Fokus, Gruppenziele, Namensplaketten +-- plate Ereignis "Namensplakette erscheint" (falls das Spiel es erlaubt) +-- xp XP-Meldung "X stirbt ..." mit passendem Ziel +-- pend XP-Meldung ohne lesbares Ziel: Name wird dem naechsten frisch gelooteten Unbekannten zugeordnet, +-- aber nur, wenn eindeutig (genau ein Name in den letzten 90 Sekunden) +-- quest Kill-Ziele von Quests (eigene und fremde), die genau eine Kreatur-ID kennen +-- cache Name war schon gesehen, bevor die Kreatur einen Datensatz hatte + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end +local Util = FW.Util + +local Names = { + stats = { target = 0, mouse = 0, scan = 0, plate = 0, xp = 0, pend = 0, quest = 0, cache = 0, tip = 0 }, + tipFail = { na = 0, err = 0, none = 0, secret = 0 }, + xpSeen = 0, + kills = {}, evCount = {}, evSecret = 0, samples = {}, killEvents = {}, + reads = 0, secret = 0, sweeps = 0, plateEvents = 0, + fail = { existSecret = 0, playerSecret = 0, guidSecret = 0, notNpc = 0, xpSecret = 0 }, + idName = {}, idCount = 0, pending = {}, running = false, plateOk = nil, +} +NS.Names = Names +Codex.Names = Names + +local MAX_CACHE = 800 +local PENDING_SECS = 90 +local SCAN_SECS = 2.5 +local QUEST_EVERY = 24 -- Durchlaeufe zwischen zwei Quest-Auswertungen (ca. eine Minute) +local TOKENS = { "target", "focus", "mouseover", "targettarget", "focustarget", "softenemy" } + +local function ParseGUID(guid) + local parse = Codex.ParseGUID + if parse then + return parse(guid) + end +end + +local function CleanName(v) + if Util.IsSecret(v) or type(v) ~= "string" then + return nil + end + v = v:gsub("[%c|]", ""):gsub("^%s+", ""):gsub("%s+$", "") + if v == "" then + return nil + end + return v:sub(1, 60) +end + +-- Kreatur kennt der Codex schon (eigene oder fremde Daten)? +local function Known(id) + if Codex.db.units[id] then + return true + end + local v = Codex.V and Codex.V() + return v and v.units and v.units[id] ~= nil +end + +-- Name zu einer Kreatur-ID eintragen. Gibt true zurueck, wenn ein neuer Name gespeichert wurde. +function Names:Learn(id, name, src) + name = CleanName(name) + if name and Codex.StripKillVerb then name = Codex.StripKillVerb(name) end + if type(id) ~= "number" or not name then + return false + end + local u = Codex.db.units[id] + if u and u.n then + return false + end + if not u and self.idName[id] then + return false + end + if u or Known(id) then + u = u or Codex:Unit(id, true) + u.n = name + self.idName[id] = nil + if src and self.stats[src] then + self.stats[src] = self.stats[src] + 1 + end + FW:Fire("CODEX_CHANGED", "unit", id) + return true + end + -- noch kein Datensatz: Name merken, bis die Kreatur gelootet wird + if not self.idName[id] and self.idCount < MAX_CACHE then + self.idName[id] = name + self.idCount = self.idCount + 1 + end + return false +end + +-- Eine Einheit lesen: nur Kreaturen, nur wenn der Name lesbar ist. +-- fail[...] zaehlt, woran es scheitert (nur zur Diagnose): exist, player, guid, npc, name +function Names:Read(unit, src) + local f = self.fail + local existsRaw = UnitExists and UnitExists(unit) + if Util.IsSecret(existsRaw) then + f.existSecret = f.existSecret + 1 + return + end + if not existsRaw then + return + end + local playerRaw = UnitIsPlayer and UnitIsPlayer(unit) + if Util.IsSecret(playerRaw) then + f.playerSecret = f.playerSecret + 1 + return + end + if playerRaw then + return + end + local guidRaw = UnitGUID and UnitGUID(unit) + if Util.IsSecret(guidRaw) then + f.guidSecret = f.guidSecret + 1 + return + end + local kind, id = ParseGUID(guidRaw) + if kind ~= "npc" then + f.notNpc = f.notNpc + 1 + return + end + self.reads = self.reads + 1 + local raw = UnitName and UnitName(unit) + if Util.IsSecret(raw) then + self.secret = self.secret + 1 + return + end + self:Learn(id, raw, src) +end + +local function GroupTokens() + local list = {} + local n = GetNumGroupMembers and GetNumGroupMembers() or 0 + if n > 1 then + local raid = IsInRaid and IsInRaid() + local prefix, count = raid and "raid" or "party", raid and math.min(n, 40) or math.min(n - 1, 4) + for i = 1, count do + list[#list + 1] = prefix .. i .. "target" + end + end + return list +end + +-- Alle bekannten Quellen einmal lesen. Im Kampf sind die Namen geheim, dann lohnt es nicht. +function Names:Scan(src) + src = src or "scan" + if UnitAffectingCombat and Util.Clean(UnitAffectingCombat("player")) then + return + end + self.sweeps = self.sweeps + 1 + for _, unit in ipairs(TOKENS) do + self:Read(unit, src) + end + for _, unit in ipairs(GroupTokens()) do + self:Read(unit, src) + end + for i = 1, 40 do + local unit = "nameplate" .. i + if UnitExists and Util.Clean(UnitExists(unit)) then + self:Read(unit, "plate") + end + end +end + +-- Kill-Ziele von Quests mit genau einer bekannten Kreatur-ID +function Names:FromQuests() + local v = Codex.V and Codex.V() + if not v or not v.quests then + return 0 + end + local learned = 0 + for _, q in pairs(v.quests) do + for _, ob in ipairs(q.obj or {}) do + if ob.ty == "monster" and ob.name and type(ob.ids) == "table" then + local only, count + count = 0 + for id in pairs(ob.ids) do + only = id + count = count + 1 + if count > 1 then + break + end + end + if count == 1 and self:Learn(only, ob.name, "quest") then + learned = learned + 1 + end + end + end + end + return learned +end + +-- XP-Meldung: Name einer getoeteten Kreatur ohne lesbares Ziel merken +function Names:NoteKill(name) + name = CleanName(name) + if not name then + return + end + local list = self.pending + list[#list + 1] = { name = name, t = time() } + while #list > 20 do + table.remove(list, 1) + end +end + +-- Versuch: Name ueber den Tooltip-Link der Beute-GUID ("unit:"). Die GUID aus dem Beutefenster ist lesbar. +function Names:FromTooltip(guid) + local api = _G.C_TooltipInfo and _G.C_TooltipInfo.GetHyperlink + if type(api) ~= "function" or type(guid) ~= "string" then + self.tipFail.na = self.tipFail.na + 1 + return nil + end + local ok, data = pcall(api, "unit:" .. guid) + if not ok then + self.tipFail.err = self.tipFail.err + 1 + return nil + end + if Util.IsSecret(data) then + self.tipFail.secret = self.tipFail.secret + 1 + return nil + end + local line = type(data) == "table" and type(data.lines) == "table" and data.lines[1] + local text = type(line) == "table" and line.leftText + if Util.IsSecret(text) then + self.tipFail.secret = self.tipFail.secret + 1 + return nil + end + local name = CleanName(text) + if not name then + self.tipFail.none = self.tipFail.none + 1 + end + return name +end + +-- Eine gelootete Kreatur mit bekanntem Namen hat ihre XP-Meldung "verbraucht" +function Names:Consume(name) + local list = self.pending + for i = #list, 1, -1 do + if list[i].name == name then + table.remove(list, i) + return + end + end +end + +-- Frisch gelootete Kreatur ohne Namen: aus Merkzettel oder XP-Meldungen auffuellen +function Names:Resolve(id, ent, guid) + if type(id) ~= "number" or type(ent) ~= "table" or ent.n then + return + end + local cached = self.idName[id] + if cached then + ent.n = cached + self.idName[id] = nil + self.stats.cache = self.stats.cache + 1 + return + end + local tip = guid and self:FromTooltip(guid) + if tip then + ent.n = tip + self.stats.tip = self.stats.tip + 1 + return + end + if (ent.k or 0) > 1 then + return + end + local now, list = time(), self.pending + for i = #list, 1, -1 do + if now - list[i].t > PENDING_SECS then + table.remove(list, i) + end + end + -- nur zuordnen, wenn in den letzten 90 Sekunden genau ein Name gemeldet wurde + local only + for _, p in ipairs(list) do + if only and only ~= p.name then + return + end + only = p.name + end + if not only then + return + end + for i = #list, 1, -1 do + if list[i].name == only then + table.remove(list, i) + break + end + end + ent.n = only + self.stats.pend = self.stats.pend + 1 +end + +-- Kampflog-Zeilen "Euer Charakter besiegt X." / "X stirbt." als Vorschlaege fuer Namen (nie automatisch zugeordnet) +local KILL_EVENTS = { "CHAT_MSG_COMBAT_HOSTILE_DEATH", "CHAT_MSG_COMBAT_SELF_HITS", "CHAT_MSG_COMBAT_PARTY_HITS", + "CHAT_MSG_COMBAT_MISC_INFO", "CHAT_MSG_COMBAT_PET_HITS" } +local KILL_PATTERNS = { + "^Euer Charakter besiegt (.+)%.$", "besiegt (.+)%.$", "^Ihr habt (.+) get\195\182tet%.$", "^(.+) stirbt%.$", + "^You have slain (.+)!$", "^You defeat (.+)%.$", "defeats (.+)%.$", "^(.+) dies%.$", +} + +-- Zeile pruefen. Rueckgabe: Name oder nil +function Names.ParseKill(text) + if type(text) ~= "string" then + return nil + end + text = text:gsub("|c%x%x%x%x%x%x%x%x", ""):gsub("|r", ""):gsub("|H.-|h(.-)|h", "%1") + for _, pattern in ipairs(KILL_PATTERNS) do + local name = text:match(pattern) + if name then + name = name:gsub("^%s+", ""):gsub("%s+$", "") + if name ~= "" and #name <= 80 then + return name + end + end + end + return nil +end + +function Names:OnKillLine(event, text) + self.evCount[event] = (self.evCount[event] or 0) + 1 + if Util.IsSecret(text) then + self.evSecret = self.evSecret + 1 + return + end + if type(text) ~= "string" then + return + end + local sample = self.samples[event] + if not sample then + sample = {} + self.samples[event] = sample + end + if #sample < 2 then + sample[#sample + 1] = text:sub(1, 90) + end + local name = Names.ParseKill(text) + if not name then + return + end + local list, now = self.kills, time() + local last = list[#list] + if last and last.name == name and now - last.t < 2 then + return + end + list[#list + 1] = { name = name, t = now } + while #list > 40 do + table.remove(list, 1) + end +end + +-- Zuletzt besiegte Gegner (neueste zuerst, ohne Doppelte). Pro Eintrag: name, alt (ohne letztes grossgeschriebenes Wort) +function Names:RecentKills(maxAge, limit) + maxAge, limit = maxAge or 900, limit or 8 + local now, seen, out = time(), {}, {} + for i = #self.kills, 1, -1 do + local k = self.kills[i] + if now - k.t <= maxAge and not seen[k.name] then + seen[k.name] = true + local alt + local head, tail = k.name:match("^(.+)%s+(%S+)$") + if head and tail:match("^%u") and #head >= 3 then + alt = head + end + out[#out + 1] = { name = k.name, alt = alt, t = k.t } + if #out >= limit then + break + end + end + end + return out +end + +local function Tick() + if not Names.running then + return + end + pcall(Names.Scan, Names, "scan") + Names.tickCount = (Names.tickCount or 0) + 1 + if Names.tickCount % QUEST_EVERY == 0 then + pcall(Names.FromQuests, Names) + end + C_Timer.After(SCAN_SECS, Tick) +end + +function Names:OnRegen() + C_Timer.After(0.5, function() pcall(Names.Scan, Names, "scan") end) + C_Timer.After(2.5, function() pcall(Names.Scan, Names, "scan") end) +end + +function Names:OnPlate(unit) + self.plateEvents = self.plateEvents + 1 + if type(unit) == "string" then + self:Read(unit, "plate") + end +end + +-- Alte Eintraege mit anhaengendem Kill-Verb ("... beseitigt", "getötet") bereinigen (einmal beim Start) +function Names.Cleanup() + local strip = Codex.StripKillVerb + if not strip or not Codex.db then return end + local n = 0 + for _, u in pairs(Codex.db.units or {}) do + if type(u) == "table" and type(u.n) == "string" then + local c = strip(u.n) + if c ~= u.n then u.n = c n = n + 1 end + end + end + for _, q in pairs(Codex.db.quests or {}) do + for _, ob in ipairs(type(q) == "table" and q.obj or {}) do + if type(ob.name) == "string" then + local c = strip(ob.name) + if c ~= ob.name then ob.name = c n = n + 1 end + end + end + end + Names.cleaned = n +end + +function Names:Start() + if self.running then + return + end + pcall(Names.Cleanup) + self.running = true + FW:RegisterEvent("PLAYER_REGEN_ENABLED", self.OnRegen, self) + -- das Ereignis kann in gesperrten Situationen verboten sein: Fehler abfangen + self.plateOk = pcall(FW.RegisterEvent, FW, "NAME_PLATE_UNIT_ADDED", self.OnPlate, self) + for _, loopEvent in ipairs(KILL_EVENTS) do + local event = loopEvent -- eigene Variable je Durchlauf: Lua 5.0 teilt die Schleifenvariable zwischen Closures + self.killEvents[event] = pcall(FW.RegisterEvent, FW, event, function(owner, text) + Names:OnKillLine(event, text) + end, Names) + end + C_Timer.After(12, function() + pcall(Names.FromQuests, Names) + end) + C_Timer.After(SCAN_SECS, Tick) +end + +function Names:Stop() + self.running = false + FW:UnregisterEvent("PLAYER_REGEN_ENABLED", self) + if self.plateOk then + FW:UnregisterEvent("NAME_PLATE_UNIT_ADDED", self) + end + for event, ok in pairs(self.killEvents) do + if ok then + FW:UnregisterEvent(event, self) + end + end +end + +function Names:Debug() + local total, named = 0, 0 + local v = Codex.V and Codex.V() or Codex.db + for _, u in pairs(v.units or {}) do + total = total + 1 + if u.n then + named = named + 1 + end + end + local st = self.stats + FW:Print(string.format("Codex-Namen: %d Kreaturen, %d mit Namen, %d ohne", total, named, total - named)) + FW:Print(string.format("Neu gelernt ueber: Ziel %d, Mouseover %d, Suche %d, Namensplakette %d, XP-Meldung %d, XP-Zuordnung %d, Quest %d, Merkzettel %d, Tooltip %d", + st.target, st.mouse, st.scan, st.plate, st.xp, st.pend, st.quest, st.cache, st.tip)) + local tf = self.tipFail + FW:Print(string.format("XP-Meldungen gesehen %d (davon geheim %d); Tooltip-Weg: nicht vorhanden %d, Fehler %d, geheim %d, ohne Namen %d", + self.xpSeen, self.fail.xpSecret, tf.na, tf.err, tf.secret, tf.none)) + local evs = {} + for event, n in pairs(self.evCount) do + evs[#evs + 1] = event:gsub("^CHAT_MSG_COMBAT_", "") .. "=" .. n + end + table.sort(evs) + FW:Print(string.format("Kampflog-Zeilen: %s (geheim %d); gemerkte Kills %d", #evs > 0 and table.concat(evs, " ") or "keine", self.evSecret, #self.kills)) + for event, list in pairs(self.samples) do + for _, text in ipairs(list) do + FW:Print(" Beispiel " .. event:gsub("^CHAT_MSG_COMBAT_", "") .. ": " .. text:gsub("|", "||")) + end + end + local f = self.fail + FW:Print(string.format("Abbrueche vor dem Namen: Einheit geheim %d, Spieler-Angabe geheim %d, GUID geheim %d, keine Kreatur %d; Instanz: %s", + f.existSecret, f.playerSecret, f.guidSecret, f.notNpc, tostring(IsInInstance and select(1, IsInInstance())))) + FW:Print(string.format("Gelesen %d, davon im Kampf geheim %d; Suchdurchlaeufe %d; Namensplaketten-Ereignis %s (%d); Merkzettel %d, offene XP-Namen %d", + self.reads, self.secret, self.sweeps, self.plateOk and "ok" or "nicht verfuegbar", self.plateEvents, self.idCount, #self.pending)) +end diff --git a/CodexOwners.lua b/CodexOwners.lua new file mode 100644 index 0000000..ee793e7 --- /dev/null +++ b/CodexOwners.lua @@ -0,0 +1,23 @@ +-- Freewood-OctoWoW-Codex: Eigentuemer der Datenbank +-- +-- Hier stehen die Charaktere (Name-Realm), die DEV-Rechte vergeben duerfen. Nur Listen, die von einem dieser +-- Charaktere kommen, werden von anderen Spielern uebernommen. Der Absender einer Addon-Nachricht wird vom +-- Spiel gesetzt und kann nicht gefaelscht werden. +-- +-- Eintrag zum Beispiel: "Lacustat-Everlook", +-- (genau so geschrieben, wie der Charakter im Spiel heisst. Charaktere haben Vor- und Nachnamen: "Aroki Steel". +-- Ein Bindestrich gilt nur als Realm-Trenner, wenn dahinter ClassicBetaPvP2 steht.) +-- +-- Ist die Liste leer, gibt es niemanden mit DEV-Rechten: Namen koennen dann nicht bearbeitet werden. + +local ADDON, NS = ... +if not NS then + return +end + +NS.Owners = { + "Yuto-ClassicBetaPvP2", + "Aroki Steel", + "Lujinja Steel", + "Lujinja-ClassicBetaPvE", +} diff --git a/CodexPath.lua b/CodexPath.lua new file mode 100644 index 0000000..1bc43de --- /dev/null +++ b/CodexPath.lua @@ -0,0 +1,1200 @@ +-- Freewood-OctoWoW-Codex: Wegenetz. Zeichnet auf, wo Spieler zu Fuss gegangen sind, als Raster aus Zellen mit Richtungen. +-- +-- Eine Zelle (CELL Yards Kantenlaenge) ist begehbar, sobald jemand zu Fuss darauf stand. Die Kanten (Schritte zu den +-- 8 Nachbarzellen) sagen, wie man von dort weitergekommen ist. Faelle werden als Einbahn-Kanten gespeichert. +-- Daraus kann der Plan spaeter Wege suchen (Hindernisse sind automatisch bekannt, weil niemand durch sie geht). +-- +-- Gespeichert wird nur bei Bewegung zu Fuss. Ausgeschlossen: Instanzen, Reittier, Flug, Taxi, freier Fall und +-- Auren, die Boden oder Wasser vortaeuschen (Levitieren, Leichter Fall, Wasserwandeln, Gleiter ...), ausserdem +-- unplausible Spruenge (Teleport, Ladebildschirm) und zu hohes Lauftempo. +-- +-- Datenform: db.path[welt][zellKey] = Maske +-- Bit 0-7 Richtungen, in die man aus der Zelle gegangen ist (O, NO, N, NW, W, SW, S, SO) +-- Bit 8-15 davon Faelle (nur in diese Richtung) +-- Bit 16 Zelle besucht +-- Zellkey = (cx + OFFSET) * 65536 + (cy + OFFSET). welt = Instanz-/Kontinent-ID aus UnitPosition. +-- db.pathz[welt][zellKey] = Hoehe in 4-Yard-Schritten, nur wenn das Spiel eine Hoehe liefert (sonst 0). +-- Gebietsuebergaenge: db.zlink[von][nach][art] = { n = Anzahl, p = { { fw, fk, tw, tk }, ... } } (bis 4 Punkte) +-- art "w" zu Fuss/Reittier ueber die Grenze (Nachbargebiete), "t" Sprung (Zeppelin, Schiff, Portal), "f" Flugpunkt, +-- "i" Instanz (nach = negative Instanz-ID; Eingang mit Startpunkt, Ausgang mit Zielpunkt). +-- von/nach sind Karten-IDs wie in den Gebieten des Codex. fw/fk = Welt und Zellkey des Startpunkts, tw/tk des Ziels. +-- Nur lokal (wird noch nicht geteilt). + +local ADDON, NS = ... + +local FW = _G.Freewood +if not FW or not FW:RequireAPI(1, ADDON) then + return +end + +local Codex = NS and NS.Codex +if not Codex then + return +end + +local Util = FW.Util +local Path = {} +Codex.Path = Path + +local CELL = 10 -- Yards je Zelle +local OFFSET = 30000 +local TICK = 1 -- Sekunden zwischen zwei Messungen +local MAX_STEP_CELLS = 3 -- weiter auseinander liegende Messungen gelten als Sprung (Teleport) +local MAX_SPEED = 18 -- Yards pro Sekunde (Reittier mit +150 % = 17,5); schneller = Effekt oder Teleport, nicht aufzeichnen +local MIN_FALL = 1.2 -- Sekunden Fall, ab denen es ein Fall ist (kein Sprung) +local MAX_CELLS = 300000 + +-- Richtungen als (dx, dy) in der Reihenfolge der Bits +local DIRS = { { 1, 0 }, { 1, 1 }, { 0, 1 }, { -1, 1 }, { -1, 0 }, { -1, -1 }, { 0, -1 }, { 1, -1 } } +local DIR_OF = {} +for i, d in ipairs(DIRS) do DIR_OF[d[1] .. ":" .. d[2]] = i - 1 end +Path.DIRS = DIRS +Path.CELL = CELL + +-- Auren, bei denen keine Daten aufgezeichnet werden (Zauber-IDs, Klassik) ... +Path.SKIP_AURAS = { + [130] = true, -- Leichter Fall + [1706] = true, -- Levitieren + [546] = true, -- Wasserwandeln + [3714] = true, -- Pfad des Frosts + [13141] = true, -- Gnomen-Raketenstiefel + [11359] = true, -- Fallschirm +} +-- ... und nach Namen (fuer Aurenen, deren ID wir nicht kennen, zum Beispiel das Gleiten einer neuen Rasse) +local SKIP_WORDS = { "levit", "leichter fall", "slow fall", "wasserwand", "water walk", "gleit", "glide", "gliding", "fallschirm", "parachute", "schweb", "hover" } + +local LINK_POINTS = 4 +local WALK_CELLS = 12 -- Wechsel mit hoechstens so vielen Zellen Abstand (120 Yards) gelten als Grenzuebergang + +local state = { acc = 0, last = nil, fallT = 0, fallFrom = nil, reasons = {}, added = 0, samples = 0 } +Path.state = state + +local function DB() + local db = Codex.db + if not db then return nil end + db.path = db.path or {} + return db +end + +local function Reject(reason) + state.reasons[reason] = (state.reasons[reason] or 0) + 1 +end + +function Path.Enabled() + local db = Codex.db + return db and db.settings and db.settings.path ~= false +end + +function Path.SetEnabled(v) + local db = Codex.db + if db and db.settings then db.settings.path = v and true or false end +end + +local function CellOf(x, y) + return math.floor(x / CELL), math.floor(y / CELL) +end + +local function Key(cx, cy) + return (cx + OFFSET) * 65536 + (cy + OFFSET) +end + +function Path.KeyToCell(key) + return math.floor(key / 65536) - OFFSET, key % 65536 - OFFSET +end + +------------------------------------------------------------------------ +-- Teilen: Wegenetz in Bloecken von 16 x 16 Zellen (160 Yards), Gebietsuebergaenge als ein Datensatz +------------------------------------------------------------------------ + +local CHUNK = 16 +local MAX_REMOTE_CELLS = 400000 +local MAX_LINKS_SHARED = 600 +local mergedCells, linkCache = {}, nil + +local function ChunkId(world, cx, cy) + if type(world) ~= "number" or world < 0 or world >= 1000000 then return nil end + local gx, gy = math.floor(cx / CHUNK) + 128, math.floor(cy / CHUNK) + 128 + if gx < 0 or gx > 255 or gy < 0 or gy > 255 then return nil end + return world * 65536 + gx * 256 + gy +end + +local function ChunkParts(id) + local world = math.floor(id / 65536) + local r = id % 65536 + return world, math.floor(r / 256), r % 256 +end + +-- Bitweises Oder fuer 17-Bit-Masken (ohne bit-Bibliothek) +local function Or17(a, b) + if a == b or b == 0 then return a end + if a == 0 then return b end + local r, p = 0, 1 + for _ = 0, 16 do + local x, y = a % 2, b % 2 + if x == 1 or y == 1 then r = r + p end + a, b, p = (a - x) / 2, (b - y) / 2, p * 2 + end + return r +end + +local function Dirty(db, world, cx, cy) + local id = ChunkId(world, cx, cy) + if not id then return end + db.pathsync = db.pathsync or {} + local now = _G.time and _G.time() or 0 + db.pathsync[id] = math.max(now, (db.pathsync[id] or 0) + 1) + mergedCells[world] = nil + Path.rev = (Path.rev or 0) + 1 +end + +local function Count(db) + if db.pathCount then return db.pathCount end + local n = 0 + for _, w in pairs(db.path) do + for _ in pairs(w) do n = n + 1 end + end + db.pathCount = n + return n +end + +-- Zelle markieren; dirIndex = Richtung, in die es weiterging (optional), drop = Fall +local function Mark(world, cx, cy, dirIndex, drop, z) + local db = DB() + if not db then return end + local w = db.path[world] + if not w then w = {}; db.path[world] = w end + local k = Key(cx, cy) + local m = w[k] + if not m then + if Count(db) >= MAX_CELLS then Reject("voll") return end + m = 0 + db.pathCount = Count(db) + 1 + state.added = state.added + 1 + end + local newm = m + if newm < 65536 then newm = newm + 65536 end -- besucht + if dirIndex then + local bit = 2 ^ dirIndex + if math.floor(newm / bit) % 2 == 0 then newm = newm + bit end + if drop then + local dbit = 2 ^ (dirIndex + 8) + if math.floor(newm / dbit) % 2 == 0 then newm = newm + dbit end + end + end + w[k] = newm + if newm ~= m then Dirty(db, world, cx, cy) end + if z and z ~= 0 then + db.pathz = db.pathz or {} + db.pathz[world] = db.pathz[world] or {} + db.pathz[world][k] = math.floor(z / 4 + 0.5) + end +end + +-- Zellen entlang der Strecke von (ax,ay) nach (bx,by) (einschliesslich beider), je Schritt hoechstens ein Nachbar +local function Line(ax, ay, bx, by) + local cells = { { ax, ay } } + local x, y = ax, ay + local n = 0 + while (x ~= bx or y ~= by) and n < 10 do + x = x + (bx > x and 1 or (bx < x and -1 or 0)) + y = y + (by > y and 1 or (by < y and -1 or 0)) + cells[#cells + 1] = { x, y } + n = n + 1 + end + return cells +end + +-- Strecke zwischen zwei Messungen eintragen +local function Connect(world, a, b, drop, z) + local cells = Line(a.cx, a.cy, b.cx, b.cy) + for i = 1, #cells do + local c = cells[i] + local nxt = cells[i + 1] + local di + local isDrop = false + if nxt then + di = DIR_OF[(nxt[1] - c[1]) .. ":" .. (nxt[2] - c[2])] + isDrop = drop and i == 1 + end + Mark(world, c[1], c[2], di, isDrop, (i == #cells) and z or nil) + end +end + +-- Hat der Spieler eine Aura, die Boden oder Wasser vortaeuscht? +local function SkipAura() + local CU = _G.C_UnitAuras + local function Check(name, id) + if id and Path.SKIP_AURAS[id] then return true end + if type(name) == "string" and not (Util and Util.IsSecret and Util.IsSecret(name)) then + local low = name:lower() + for _, w in ipairs(SKIP_WORDS) do + if low:find(w, 1, true) then return true end + end + end + return false + end + if CU and CU.GetAuraDataByIndex then + for i = 1, 40 do + local ok, a = pcall(CU.GetAuraDataByIndex, "player", i, "HELPFUL") + if not ok or type(a) ~= "table" then break end + if Check(a.name, a.spellId) then return true end + end + return false + end + if _G.UnitBuff then + for i = 1, 40 do + local ok, name, _, _, _, _, _, _, _, _, id = pcall(_G.UnitBuff, "player", i) + if not ok or not name then break end + if Check(name, id) then return true end + end + end + return false +end + +------------------------------------------------------------------------ +-- Gebietsuebergaenge +------------------------------------------------------------------------ + +local function Flag(fn, ...) + if type(fn) ~= "function" then return false end + local ok, v = pcall(fn, ...) + return ok and v and true or false +end + + +local function Here(pos) + if not pos then return nil end + local cx, cy = CellOf(pos.x, pos.y) + return { world = pos.world, key = Key(cx, cy) } +end + +local function CurrentMap() + local CM = _G.C_Map + if not (CM and CM.GetBestMapForUnit) then return nil end + local ok, m = pcall(CM.GetBestMapForUnit, "player") + if not ok or type(m) ~= "number" or (Util and Util.IsSecret and Util.IsSecret(m)) then return nil end + return m +end + +local function Link(a, b, kind, from, to) + local db = DB() + if not db or a == b then return end + db.zlink = db.zlink or {} + local la = db.zlink[a] + if not la then la = {}; db.zlink[a] = la end + local lb = la[b] + if not lb then lb = {}; la[b] = lb end + local e = lb[kind] + if not e then e = { n = 0, p = {} }; lb[kind] = e end + linkCache = nil + e.n = e.n + 1 + local p = { fw = from and from.world, fk = from and from.key, tw = to and to.world, tk = to and to.key } + if #e.p < LINK_POINTS then + for _, q in ipairs(e.p) do + if q.fk == p.fk and q.tk == p.tk then return end + end + e.p[#e.p + 1] = p + end +end + +-- Benachbarte Gebiete: Liste { map, n, kinds = { w, t, f, i } } (beide Richtungen zusammen) +function Path.Neighbors(map) + local db = Codex.db + local out, idx = {}, {} + local function Add(other, kinds) + local e = idx[other] + if not e then e = { map = other, n = 0, kinds = {} }; idx[other] = e; out[#out + 1] = e end + for kind, v in pairs(kinds) do + e.kinds[kind] = (e.kinds[kind] or 0) + v.n + e.n = e.n + v.n + end + end + for a, row in pairs(Path.Links()) do + for b, kinds in pairs(row) do + if a == map then Add(b, kinds) elseif b == map then Add(a, kinds) end + end + end + table.sort(out, function(x, y) return x.n > y.n end) + return out +end + +-- Uebergangspunkte von a nach b: Liste { fw, fk, tw, tk } je Art, oder nil +function Path.Crossings(a, b, kind) + local db = Codex.db + local L = Path.Links() + local e = L[a] and L[a][b] and L[a][b][kind or "w"] + return e and e.p or nil +end + +function Path.ClearRemote() + local db = Codex.db + if not db then return end + db.pathr, db.pathrCount, db.zlinkr = nil, nil, nil + mergedCells, linkCache = {}, nil + Path.rev = (Path.rev or 0) + 1 +end + +-- Eigene und empfangene Zellen einer Welt (Maske = Oder beider). Ohne Fremddaten ist das die eigene Tabelle. +function Path.Cells(world) + local db = Codex.db + if not db then return nil end + local own = db.path and db.path[world] + local rem = db.pathr and db.pathr[world] + if not rem then return own end + if not own then return rem end + local m = mergedCells[world] + if m then return m end + m = {} + for k, v in pairs(rem) do m[k] = v end + for k, v in pairs(own) do m[k] = m[k] and Or17(m[k], v) or v end + mergedCells[world] = m + return m +end + +local function MergeLinkInto(out, a, b, kind, n, pts, cap) + out[a] = out[a] or {} + out[a][b] = out[a][b] or {} + local t = out[a][b][kind] + if not t then + t = { n = n, p = {} } + out[a][b][kind] = t + else + t.n = math.max(t.n, n) + end + for _, p in ipairs(pts or {}) do + local dup = false + for _, q in ipairs(t.p) do + if q.fk == p.fk and q.tk == p.tk then dup = true break end + end + if not dup and #t.p < cap then t.p[#t.p + 1] = p end + end +end + +-- Eigene und empfangene Gebietsuebergaenge zusammen +function Path.Links() + local db = Codex.db + if not db then return {} end + if not (db.zlinkr and next(db.zlinkr)) then return db.zlink or {} end + if linkCache then return linkCache end + local out = {} + for _, src in ipairs({ db.zlink or {}, db.zlinkr }) do + for a, row in pairs(src) do + for b, kinds in pairs(row) do + for kind, e in pairs(kinds) do MergeLinkInto(out, a, b, kind, e.n, e.p, 8) end + end + end + end + linkCache = out + return out +end + +------------------------------------------------------------------------ +-- Teilen: eigene Bloecke, Pruefen und Einmischen +------------------------------------------------------------------------ + +-- Staende der eigenen Bloecke einmalig anlegen (Daten, die vor dem Teilen aufgezeichnet wurden) +function Path.EnsureStamps() + local db = Codex.db + if not db or db.pathStamped then return end + db.pathsync = db.pathsync or {} + local now = _G.time and _G.time() or 0 + for world, w in pairs(db.path or {}) do + for key in pairs(w) do + local cx, cy = Path.KeyToCell(key) + local id = ChunkId(world, cx, cy) + if id and not db.pathsync[id] then db.pathsync[id] = now end + end + end + db.pathStamped = true +end + +local function OwnLinkSig() + local db = Codex.db + local s, any = 0, false + for a, row in pairs(db and db.zlink or {}) do + for b, kinds in pairs(row) do + for _, e in pairs(kinds) do + s = s + e.n * 7 + #(e.p or {}) + 1 + any = true + end + end + end + return any and s % 2147483647 or nil +end + +-- Verzeichnis der eigenen Bloecke: Liste { key, upd } +function Path.SyncList() + local db = Codex.db + local out = {} + if not db or not Path.Enabled() and not db.pathsync then return out end + Path.EnsureStamps() + for id, upd in pairs(db.pathsync or {}) do out[#out + 1] = { key = "p" .. id, upd = upd } end + local sig = OwnLinkSig() + if sig then + local zs = db.zsync + if not zs then zs = {}; db.zsync = zs end + if zs.sig ~= sig then + zs.sig = sig + zs.upd = math.max(_G.time and _G.time() or 0, (zs.upd or 0) + 1) + end + out[#out + 1] = { key = "z0", upd = zs.upd } + end + return out +end + +-- Eigener Block als Text "Zelle.Maske,..." (Zelle = 0..255 im Block, beides hexadezimal) +function Path.ChunkRecord(id) + local db = Codex.db + if not db or type(id) ~= "number" then return nil end + local world, gx, gy = ChunkParts(id) + local w = db.path and db.path[world] + if not w then return nil end + local bx, by = (gx - 128) * CHUNK, (gy - 128) * CHUNK + local parts = {} + for dx = 0, CHUNK - 1 do + for dy = 0, CHUNK - 1 do + local m = w[Key(bx + dx, by + dy)] + if m then parts[#parts + 1] = string.format("%x.%x", dx * CHUNK + dy, m) end + end + end + if #parts == 0 then return nil end + return { c = table.concat(parts, ",") } +end + +-- Text pruefen. Rueckgabe: Tabelle { [zelle] = maske } oder nil +function Path.ParseChunk(str) + if type(str) ~= "string" or #str > 6000 then return nil end + local out, n = {}, 0 + for idx, mask in str:gmatch("(%x+)%.(%x+)") do + local i, m = tonumber(idx, 16), tonumber(mask, 16) + if not i or not m or i > 255 or m > 131071 or m < 0 then return nil end + if not out[i] then n = n + 1 end + out[i] = m + if n > 256 then return nil end + end + if n == 0 then return nil end + return out +end + +function Path.ChunkText(cells) + local keys = {} + for i in pairs(cells) do keys[#keys + 1] = i end + table.sort(keys) + local parts = {} + for _, i in ipairs(keys) do parts[#parts + 1] = string.format("%x.%x", i, cells[i]) end + return table.concat(parts, ",") +end + +-- Empfangenen Block einmischen (Oder der Masken; mehrfach einspielen aendert nichts) +function Path.MergeChunk(id, cells) + local db = Codex.db + if not db or type(id) ~= "number" or type(cells) ~= "table" then return false end + local world, gx, gy = ChunkParts(id) + db.pathr = db.pathr or {} + local w = db.pathr[world] + if not w then w = {}; db.pathr[world] = w end + db.pathrCount = db.pathrCount or 0 + local bx, by = (gx - 128) * CHUNK, (gy - 128) * CHUNK + local changed = false + for idx, mask in pairs(cells) do + local k = Key(bx + math.floor(idx / CHUNK), by + idx % CHUNK) + local old = w[k] + if not old then + if db.pathrCount >= MAX_REMOTE_CELLS then return changed end + db.pathrCount = db.pathrCount + 1 + w[k] = mask + changed = true + else + local nm = Or17(old, mask) + if nm ~= old then w[k] = nm; changed = true end + end + end + if changed then mergedCells[world] = nil; Path.rev = (Path.rev or 0) + 1 end + return changed +end + +local KINDS = { w = true, t = true, f = true, i = true } + +local function OptInt(v, hi) + if v == nil then return nil, true end + if type(v) ~= "number" or v ~= v or v < 0 or v > hi then return nil, false end + return math.floor(v), true +end + +-- Eigene Gebietsuebergaenge als Liste { a, b, k, n, p = { { fw, fk, tw, tk } } } +function Path.LinkRecord() + local db = Codex.db + local list = {} + local keys = {} + for a, row in pairs(db and db.zlink or {}) do + for b, kinds in pairs(row) do + for kind, e in pairs(kinds) do keys[#keys + 1] = { a = a, b = b, k = kind, e = e } end + end + end + table.sort(keys, function(x, y) + if x.a ~= y.a then return x.a < y.a end + if x.b ~= y.b then return x.b < y.b end + return x.k < y.k + end) + for _, r in ipairs(keys) do + if #list >= MAX_LINKS_SHARED then break end + local pts = {} + for _, p in ipairs(r.e.p or {}) do + pts[#pts + 1] = { fw = p.fw, fk = p.fk, tw = p.tw, tk = p.tk } + end + list[#list + 1] = { a = r.a, b = r.b, k = r.k, n = r.e.n, p = pts } + end + if #list == 0 then return nil end + return { l = list } +end + +-- Liste pruefen. Rueckgabe: bereinigte Liste oder nil +function Path.ParseLinks(list) + if type(list) ~= "table" or #list == 0 or #list > MAX_LINKS_SHARED then return nil end + local out = {} + for _, r in ipairs(list) do + if type(r) ~= "table" then return nil end + local a, b, n = r.a, r.b, r.n + if type(a) ~= "number" or type(b) ~= "number" or a ~= a or b ~= b or math.abs(a) > 1000000 or math.abs(b) > 1000000 or a == b then return nil end + if type(r.k) ~= "string" or not KINDS[r.k] then return nil end + if type(n) ~= "number" or n ~= n or n < 1 or n > 1000000 then return nil end + local pts = {} + for _, p in ipairs(type(r.p) == "table" and r.p or {}) do + if #pts >= LINK_POINTS then break end + if type(p) ~= "table" then return nil end + local fw, ok1 = OptInt(p.fw, 1000000) + local tw, ok2 = OptInt(p.tw, 1000000) + local fk, ok3 = OptInt(p.fk, 2 ^ 40) + local tk, ok4 = OptInt(p.tk, 2 ^ 40) + if not (ok1 and ok2 and ok3 and ok4) then return nil end + pts[#pts + 1] = { fw = fw, fk = fk, tw = tw, tk = tk } + end + out[#out + 1] = { a = math.floor(a), b = math.floor(b), k = r.k, n = math.floor(n), p = pts } + end + return out +end + +function Path.MergeLinks(list) + local db = Codex.db + if not db or type(list) ~= "table" then return false end + db.zlinkr = db.zlinkr or {} + for _, r in ipairs(list) do MergeLinkInto(db.zlinkr, r.a, r.b, r.k, r.n, r.p, LINK_POINTS) end + linkCache = nil + return true +end + +------------------------------------------------------------------------ +-- Wegberechnung ueber das Wegenetz (fuer den Levelplan) +------------------------------------------------------------------------ +-- Das Netz wird zu Bloecken von 4 x 4 Zellen (40 Yards) vergroebert; Kanten gibt es dort, wo Zellen in +-- verschiedene Bloecke fuehren (Richtungen) oder aneinander grenzen. Faelle gelten nur in Fallrichtung. +-- Distance liefert die Strecke in Yards laengs des Netzes (inklusive der Strecke von und zu den naechsten +-- Netzpunkten), oder nil, wenn dort kein zusammenhaengender Weg bekannt ist (dann rechnet der Plan in Luftlinie). + +local BLOCK = 4 +local SNAP_BLOCKS = 3 -- so weit (in Bloecken) darf ein Ort vom Netz entfernt sein +local MAX_EXPAND = 20000 +local GRAPH_STALE = 120 -- Sekunden, die ein vorhandener Graph trotz neuer Daten weiterverwendet wird +local graphs = {} + +local function BKey(bx, by) + return (bx + 8000) * 65536 + (by + 8000) +end + +local function Now() + return _G.time and _G.time() or 0 +end + +-- Fuer jede Richtungsmaske (8 Bit) die Liste der gesetzten Richtungen (Index 1..8) +local DIRLIST = {} +for m = 0, 255 do + local l = {} + for i = 0, 7 do + if math.floor(m / 2 ^ i) % 2 == 1 then l[#l + 1] = i + 1 end + end + DIRLIST[m] = l +end + +local jobs = {} -- [welt] = laufender Aufbau +local GRAPH_PER_FRAME = 500 -- Zellen je Bild beim Aufbau im Hintergrund + +local function StartJob(world) + local cells = Path.Cells(world) + if not cells or not next(cells) then graphs[world] = false return nil end + local keys = {} + for k in pairs(cells) do keys[#keys + 1] = k end + local job = { world = world, cells = cells, keys = keys, i = 1, nodes = {}, built = Now(), rev = Path.rev or 0 } + jobs[world] = job + return job +end + +-- bis zu n Zellen verarbeiten; true, wenn alle durch sind +local function Step(job, n) + local cells, nodes, keys = job.cells, job.nodes, job.keys + local floor = math.floor + local function Node(bx, by) + local k = (bx + 8000) * 65536 + (by + 8000) + local nd = nodes[k] + if not nd then nd = { bx = bx, by = by, adj = {} }; nodes[k] = nd end + return nd, k + end + local function Edge(a, bk, cost) + local old = a.adj[bk] + if not old or cost < old then a.adj[bk] = cost end + end + local STRAIGHT, DIAG = BLOCK * CELL, BLOCK * CELL * 1.414 + local last = math.min(#keys, job.i + n - 1) + for idx = job.i, last do + local key = keys[idx] + local mask = cells[key] + if mask then + local cx, cy = floor(key / 65536) - OFFSET, key % 65536 - OFFSET + local bx, by = floor(cx / BLOCK), floor(cy / BLOCK) + local a, ak + local dirs = mask % 256 + local drops = floor(mask / 256) % 256 + if dirs > 0 then + for _, di in ipairs(DIRLIST[dirs]) do + local d = DIRS[di] + local nbx, nby = floor((cx + d[1]) / BLOCK), floor((cy + d[2]) / BLOCK) + if nbx ~= bx or nby ~= by then + if not a then a, ak = Node(bx, by) end + local b, bk = Node(nbx, nby) + local cost = (nbx ~= bx and nby ~= by) and DIAG or STRAIGHT + Edge(a, bk, cost) + if floor(drops / 2 ^ (di - 1)) % 2 == 0 then Edge(b, ak, cost) end + end + end + end + if drops == 0 then + -- Nachbarzelle aus einem anderen Weg: verbunden + for i = 1, 4 do + local d = DIRS[i] + local nm = cells[key + d[1] * 65536 + d[2]] + if nm and floor(nm / 256) % 256 == 0 then + local nbx, nby = floor((cx + d[1]) / BLOCK), floor((cy + d[2]) / BLOCK) + if nbx ~= bx or nby ~= by then + if not a then a, ak = Node(bx, by) end + local b, bk = Node(nbx, nby) + local cost = (nbx ~= bx and nby ~= by) and DIAG or STRAIGHT + Edge(a, bk, cost) + Edge(b, ak, cost) + end + end + end + end + end + end + job.i = last + 1 + return job.i > #keys +end + +-- Zusammenhangskomponenten (Kanten als ungerichtet), damit unerreichbare Ziele sofort auffallen +local function Finish(job) + local nodes = job.nodes + local parent = {} + local function Find(k) + local r = k + while parent[r] ~= r do r = parent[r] end + while parent[k] ~= r do local nx = parent[k]; parent[k] = r; k = nx end + return r + end + for k in pairs(nodes) do parent[k] = k end + for k, nd in pairs(nodes) do + for nk in pairs(nd.adj) do + local a, b = Find(k), Find(nk) + if a ~= b then parent[a] = b end + end + end + local comp = {} + for k in pairs(nodes) do comp[k] = Find(k) end + return { nodes = nodes, comp = comp, built = job.built, rev = job.rev, cache = {}, snaps = {} } +end + +local function BuildGraph(world) + local job = StartJob(world) + if not job then return nil end + Step(job, #job.keys + 1) + jobs[world] = nil + return Finish(job) +end + +local function Graph(world) + local g = graphs[world] + local rev = Path.rev or 0 + if g and (g.rev == rev or Now() - g.built < GRAPH_STALE) then return g end + if Path.syncGraph then + g = BuildGraph(world) + graphs[world] = g or false + return g + end + if not jobs[world] then StartJob(world) end + return g or nil +end + +-- Aufbau im Hintergrund voranbringen (je Bild); true, wenn ein Graph fertig wurde +function Path.StepJobs(n) + local world, job = next(jobs) + if not job then return false end + if Step(job, n or GRAPH_PER_FRAME) then + jobs[world] = nil + graphs[world] = Finish(job) + FW:Fire("CODEX_PATH_GRAPH", world) + return true + end + return false +end + +local graphFrame = _G.CreateFrame and _G.CreateFrame("Frame") +if graphFrame then + graphFrame:SetScript("OnUpdate", function() Path.StepJobs() end) +end + +-- Naechster Netzpunkt zu (x, y) in Yards. Rueckgabe: Knotenschluessel, Entfernung in Yards +local function Snap(g, x, y) + local sk = math.floor(x / 10) * 100000 + math.floor(y / 10) + local c = g.snaps[sk] + if c ~= nil then + if c == false then return nil end + return c[1], c[2] + end + local bx, by = math.floor(x / (BLOCK * CELL)), math.floor(y / (BLOCK * CELL)) + local best, bestD + for dx = -SNAP_BLOCKS, SNAP_BLOCKS do + for dy = -SNAP_BLOCKS, SNAP_BLOCKS do + local k = BKey(bx + dx, by + dy) + if g.nodes[k] then + local cxm, cym = (bx + dx + 0.5) * BLOCK * CELL, (by + dy + 0.5) * BLOCK * CELL + local d = math.sqrt((cxm - x) ^ 2 + (cym - y) ^ 2) + if not bestD or d < bestD then best, bestD = k, d end + end + end + end + if bestD and bestD > SNAP_BLOCKS * BLOCK * CELL then best = nil end + g.snaps[sk] = best and { best, bestD } or false + return best, bestD +end + +-- A* ueber die Bloecke. Rueckgabe: Strecke in Yards oder nil +local function Search(g, from, to) + local nodes = g.nodes + local goal = nodes[to] + if not goal then return nil end + local gx, gy = (goal.bx + 0.5) * BLOCK * CELL, (goal.by + 0.5) * BLOCK * CELL + local function H(n) + return 1.2 * math.sqrt(((n.bx + 0.5) * BLOCK * CELL - gx) ^ 2 + ((n.by + 0.5) * BLOCK * CELL - gy) ^ 2) + end + local dist, heap, size, done = { [from] = 0 }, {}, 0, {} + local function Push(k, pr) + size = size + 1 + local i = size + heap[i] = { k, pr } + while i > 1 do + local p = math.floor(i / 2) + if heap[p][2] <= heap[i][2] then break end + heap[p], heap[i] = heap[i], heap[p] + i = p + end + end + local function Pop() + local top = heap[1] + heap[1] = heap[size] + heap[size] = nil + size = size - 1 + local i = 1 + while true do + local l, r, m = i * 2, i * 2 + 1, i + if l <= size and heap[l][2] < heap[m][2] then m = l end + if r <= size and heap[r][2] < heap[m][2] then m = r end + if m == i then break end + heap[m], heap[i] = heap[i], heap[m] + i = m + end + return top[1] + end + Push(from, H(nodes[from])) + local expanded = 0 + while size > 0 do + local k = Pop() + if k == to then return dist[k] end + if not done[k] then + done[k] = true + expanded = expanded + 1 + if expanded > MAX_EXPAND then return nil end + for nk, cost in pairs(nodes[k].adj) do + local nd = dist[k] + cost + if not dist[nk] or nd < dist[nk] then + dist[nk] = nd + Push(nk, nd + H(nodes[nk])) + end + end + end + end + return nil +end + +-- Reihenfolge der Weltkoordinaten der Kartenschnittstelle: false = (erster, zweiter) wie UnitPosition, true = (zweiter, erster). +-- Gespeichert in settings.pathSwap (dieselbe Erkennung wie in der Kartenanzeige). +function Path.Swap() + local db = Codex.db + if not db or not db.settings then return nil end + if type(db.settings.pathSwap) == "boolean" then return db.settings.pathSwap end + local CM = _G.C_Map + if not (CM and CM.GetBestMapForUnit and CM.GetPlayerMapPosition and CM.GetWorldPosFromMapPos and _G.CreateVector2D) then return nil end + local pos = Path.Read() + if not pos then return nil end + local ok, mapID = pcall(CM.GetBestMapForUnit, "player") + if not ok or type(mapID) ~= "number" then return nil end + local ok2, mp = pcall(CM.GetPlayerMapPosition, mapID, "player") + if not ok2 or not mp or not mp.GetXY then return nil end + local px, py = mp:GetXY() + if type(px) ~= "number" or type(py) ~= "number" then return nil end + local ok3, _, wp = pcall(CM.GetWorldPosFromMapPos, mapID, _G.CreateVector2D(px, py)) + if not ok3 or not wp or not wp.GetXY then return nil end + local vx, vy = wp:GetXY() + if type(vx) ~= "number" or type(vy) ~= "number" then return nil end + -- pos.y = erster Wert von UnitPosition, pos.x = zweiter + local errNormal = math.abs(vx - pos.y) + math.abs(vy - pos.x) + local errSwap = math.abs(vx - pos.x) + math.abs(vy - pos.y) + if math.abs(errNormal - errSwap) < 50 then return nil end -- zu nah beieinander, nicht eindeutig + db.settings.pathSwap = errSwap < errNormal + return db.settings.pathSwap +end + +-- Weltkoordinaten der Kartenschnittstelle (Vektor x, y in Yards) -> Zellraum (x, y in Yards) +function Path.FromWorldVec(vx, vy) + local swap = Path.Swap() + if swap == nil then return nil end + if swap then return vx, vy end + return vy, vx +end + +-- Strecke in Yards laengs des Wegenetzes zwischen zwei Orten (Zellraum, Yards); nil = unbekannt, Luftlinie benutzen +function Path.Distance(world, ax, ay, bx, by) + if type(world) ~= "number" then return nil end + local straight = math.sqrt((ax - bx) ^ 2 + (ay - by) ^ 2) + if straight < 2 * BLOCK * CELL then return nil end + local g = Graph(world) + if not g then return nil end + local sa, da = Snap(g, ax, ay) + local sb, db_ = Snap(g, bx, by) + if not sa or not sb or sa == sb then return nil end + if g.comp[sa] ~= g.comp[sb] then return nil end + local ck = sa .. ":" .. sb + local cost = g.cache[ck] + if cost == nil then + cost = Search(g, sa, sb) or false + g.cache[ck] = cost + end + if not cost then return nil end + return da + cost + db_ +end + +function Path.ResetGraphs() + graphs = {} +end + +-- Wird bei jeder Messung aufgerufen, auch beim Reiten und Fliegen (Grenzen gelten fuer alle) +function Path.TrackZone(pos) + local z = state.zone + if Flag(_G.IsInInstance) then + if not state.inInst then + local id = Codex.CurrentInstance and Codex.CurrentInstance() + state.inInst = id and -id or true + if z and z.map and id then Link(z.map, -id, "i", z.here, nil) end + state.leftFrom = id and -id or nil + end + state.pend = nil + return + end + if state.inInst then + state.inInst = nil + state.zone, z = nil, nil + end + if Flag(_G.UnitOnTaxi, "player") then + if not state.taxi and z then state.taxi = { map = z.map, here = z.here } end + return + end + local m = CurrentMap() + if not m then return end + local here = Here(pos) + if state.leftFrom then + Link(state.leftFrom, m, "i", nil, here) + state.leftFrom = nil + end + if state.taxi then + local t = state.taxi + state.taxi = nil + if t.map and t.map ~= m then Link(t.map, m, "f", t.here, here) end + state.zone, state.pend = { map = m, here = here }, nil + return + end + if not z then + state.zone = { map = m, here = here } + return + end + if m == z.map then + state.pend = nil + z.here = here or z.here + return + end + -- anderes Gebiet: erst nach zwei Messungen in Folge werten (an der Grenze hin und her tritt oft doppelt) + if not state.pend or state.pend.map ~= m then + state.pend = { map = m, n = 1 } + return + end + state.pend.n = state.pend.n + 1 + if state.pend.n < 2 then return end + local kind = "t" + if z.here and here and z.here.world == here.world then + local fx, fy = Path.KeyToCell(z.here.key) + local tx, ty = Path.KeyToCell(here.key) + if math.max(math.abs(fx - tx), math.abs(fy - ty)) <= WALK_CELLS then kind = "w" end + end + Link(z.map, m, kind, z.here, here) + state.zone, state.pend = { map = m, here = here }, nil +end + +-- Aktuelle Messung: { x, y, z, world } oder nil, wenn nicht lesbar +function Path.Read() + if not _G.UnitPosition then return nil end + local ok, a, b, c, d = pcall(_G.UnitPosition, "player") + if not ok then return nil end + for _, v in ipairs({ a or 0, b or 0, c or 0, d or 0 }) do + if Util and Util.IsSecret and Util.IsSecret(v) then return nil end + end + if type(a) ~= "number" or type(b) ~= "number" then return nil end + -- UnitPosition liefert (Y, X, Z, Welt) + return { x = b, y = a, z = type(c) == "number" and c or 0, world = d } +end + +-- Eine Messung verarbeiten. now = Zeit in Sekunden. Rueckgabe: true, wenn aufgezeichnet. +function Path.Tick(now) + state.samples = state.samples + 1 + if not Path.Enabled() or not DB() then return false end + local reset = function(reason) + if reason then Reject(reason) end + state.last = nil + state.fallT, state.fallFrom = 0, nil + return false + end + local pos0 = Path.Read() + pcall(Path.TrackZone, pos0) + if Flag(_G.IsInInstance) then return reset("instanz") end + local pos = pos0 + if not pos or not pos.world then return reset("keine Position") end + local dead = _G.UnitIsDeadOrGhost and Flag(_G.UnitIsDeadOrGhost, "player") + if dead then return reset("tot") end + -- Reiten zaehlt mit (Bodenreittiere folgen dem Boden); Flug, Taxi und Fahrzeuge nicht + if Flag(_G.IsFlying) or Flag(_G.UnitOnTaxi, "player") or (_G.UnitInVehicle and Flag(_G.UnitInVehicle, "player")) then + return reset("Flug/Fahrzeug") + end + -- Fahrt wird als Verbindung erfasst (Start hier ... Ziel hier): Deck und Fahrweg gehoeren nicht ins Wegenetz + local T = Codex.Transport + if T and T.Recording and T.Recording() then return reset("Verbindung") end + local speed = _G.GetUnitSpeed and select(1, _G.GetUnitSpeed("player")) or 0 + if type(speed) == "number" and speed > MAX_SPEED then return reset("zu schnell") end + if SkipAura() then return reset("Aura") end + local cx, cy = CellOf(pos.x, pos.y) + local cur = { cx = cx, cy = cy, world = pos.world } + if Flag(_G.IsFalling) then + -- Fall: nichts eintragen, aber den Startpunkt merken + if not state.fallFrom and state.last and state.last.world == pos.world then state.fallFrom = state.last end + state.fallT = state.fallT + TICK + return false + end + if state.fallFrom then + -- gelandet: war es ein richtiger Fall (kein Sprung), Einbahn-Kante vom Start zur Landung + local from, long = state.fallFrom, state.fallT >= MIN_FALL + state.fallFrom, state.fallT = nil, 0 + if long and from.world == pos.world and math.max(math.abs(cx - from.cx), math.abs(cy - from.cy)) <= 8 then + local cells = Line(from.cx, from.cy, cx, cy) + local nxt = cells[2] + if nxt then + local di = DIR_OF[(nxt[1] - from.cx) .. ":" .. (nxt[2] - from.cy)] + Mark(pos.world, from.cx, from.cy, di, true) + end + Mark(pos.world, cx, cy, nil, false, pos.z) + state.last = cur + return true + end + end + local last = state.last + if last and last.world == pos.world then + local d = math.max(math.abs(cx - last.cx), math.abs(cy - last.cy)) + if d == 0 then return false end -- steht still: keine neuen Daten + if d > MAX_STEP_CELLS then + state.last = cur -- Teleport oder Ladebildschirm: neu ansetzen, nichts verbinden + Reject("Sprung") + Mark(pos.world, cx, cy, nil, false, pos.z) + return false + end + Connect(pos.world, last, cur, false, pos.z) + else + Mark(pos.world, cx, cy, nil, false, pos.z) + end + state.last = cur + return true +end + +-- Ist an dieser Stelle (Weltkoordinaten in Yards) schon etwas bekannt? Rueckgabe: Maske oder nil +function Path.At(world, x, y) + local db = Codex.db + if not db then return nil end + local w = Path.Cells(world) + if not w then return nil end + local cx, cy = CellOf(x, y) + return w[Key(cx, cy)] +end + +-- Kennzahlen fuer die Diagnose +function Path.Stats() + local db = Codex.db + local cells, drops, edges, worlds, links, crossings = 0, 0, 0, 0, 0, 0 + for _, w in pairs(db and db.path or {}) do + worlds = worlds + 1 + for _, m in pairs(w) do + cells = cells + 1 + for i = 0, 7 do + if math.floor(m / 2 ^ i) % 2 == 1 then edges = edges + 1 end + if math.floor(m / 2 ^ (i + 8)) % 2 == 1 then drops = drops + 1 end + end + end + end + for _, row in pairs(Path.Links()) do + for _, kinds in pairs(row) do + links = links + 1 + for _, e in pairs(kinds) do crossings = crossings + e.n end + end + end + local shared = 0 + for _, w in pairs(db and db.pathr or {}) do + for _ in pairs(w) do shared = shared + 1 end + end + return { cells = cells, edges = edges, drops = drops, worlds = worlds, links = links, crossings = crossings, shared = shared } +end + +------------------------------------------------------------------------ +-- Messen: alle TICK Sekunden +------------------------------------------------------------------------ + +local frame = _G.CreateFrame and _G.CreateFrame("Frame") +if frame then + frame:SetScript("OnUpdate", function(_, elapsed) + state.acc = state.acc + (elapsed or 0) + if state.acc < TICK then return end + state.acc = 0 + local ok = pcall(Path.Tick, _G.GetTime and _G.GetTime() or 0) + if not ok then Reject("Fehler") end + end) +end + +------------------------------------------------------------------------ +-- Einmalige Bereinigung: fremde Wege aus frueheren Startdaten (Original-Freewood) stammen nicht von Octo-Spielern +-- und blieben im gespeicherten Datenbestand liegen. Empfangene Wege kommen beim naechsten Sync wieder. +------------------------------------------------------------------------ + +function Path.CleanOld() + local db = Codex.db + if not db or db.pathCleanV1 then return end + db.pathCleanV1 = true + local n = db.pathrCount or 0 + if n > 0 and Codex.Share and Codex.Share.ClearRemote then + Codex.Share.ClearRemote() + FW:Print(string.format(FW.L.CODEX_PATH_CLEANED, n)) + FW:Fire("CODEX_CHANGED", "import") + FW:Fire("CODEX_SYNC_CHANGED") + end +end + +local cleaner = _G.CreateFrame and _G.CreateFrame("Frame") +if cleaner then + cleaner:RegisterEvent("PLAYER_ENTERING_WORLD") + cleaner:SetScript("OnEvent", function(self) + self:UnregisterEvent("PLAYER_ENTERING_WORLD") + if _G.C_Timer and _G.C_Timer.After then _G.C_Timer.After(20, function() pcall(Path.CleanOld) end) end + end) +end + +------------------------------------------------------------------------ +-- /fwpos: Diagnose (Position, Hoehe, Zustand, Auren) und Ein/Aus +------------------------------------------------------------------------ + +function Path.Diag() + local pos = Path.Read() + if not pos then + FW:Print("Pos: keine Position lesbar (UnitPosition fehlt, Instanz oder gesperrt).") + else + FW:Print(string.format("Pos: x=%.1f y=%.1f z=%s Welt=%s -> Zelle %d/%d%s", pos.x, pos.y, tostring(pos.z), tostring(pos.world), + (CellOf(pos.x, pos.y)), select(2, CellOf(pos.x, pos.y)), pos.z == 0 and " (Hoehe liefert das Spiel nicht: z ist 0)" or " (Hoehe wird gespeichert)")) + end + local speed = _G.GetUnitSpeed and select(1, _G.GetUnitSpeed("player")) or "?" + FW:Print(string.format("Pos: Tempo=%s | Reittier=%s Flug=%s Fall=%s Schwimmen=%s Instanz=%s", tostring(speed), tostring(Flag(_G.IsMounted)), + tostring(Flag(_G.IsFlying)), tostring(Flag(_G.IsFalling)), tostring(Flag(_G.IsSwimming)), tostring(Flag(_G.IsInInstance)))) + local names = {} + local CU = _G.C_UnitAuras + if CU and CU.GetAuraDataByIndex then + for i = 1, 40 do + local ok, a = pcall(CU.GetAuraDataByIndex, "player", i, "HELPFUL") + if not ok or type(a) ~= "table" then break end + names[#names + 1] = tostring(a.name) .. "(" .. tostring(a.spellId) .. ")" + end + elseif _G.UnitBuff then + for i = 1, 40 do + local ok, name, _, _, _, _, _, _, _, _, id = pcall(_G.UnitBuff, "player", i) + if not ok or not name then break end + names[#names + 1] = tostring(name) .. "(" .. tostring(id) .. ")" + end + end + FW:Print("Pos: Auren: " .. (#names > 0 and table.concat(names, ", ") or "keine") .. " | wird uebersprungen: " .. tostring(SkipAura())) + local st = Path.Stats() + FW:Print(string.format("Pos: Aufzeichnung %s | %d Zellen, %d Kanten, %d Faelle, %d Welten | neu in dieser Sitzung: %d", Path.Enabled() and "an" or "aus", + st.cells, st.edges, st.drops, st.worlds, state.added)) + FW:Print(string.format("Pos: %d Gebietsverbindungen (%d Uebergaenge)", st.links, st.crossings)) + local r = {} + for reason, n in pairs(state.reasons) do r[#r + 1] = reason .. " x" .. n end + table.sort(r) + if #r > 0 then FW:Print("Pos: verworfen: " .. table.concat(r, ", ")) end +end + +-- Einstellungen: Ein/Aus (standardmaessig an) und Kennzahlen +local UI = FW.UI +if UI and UI.RegisterSettingsSection then + UI:RegisterSettingsSection("codex-path", { + order = 107, + height = 200, + build = function(parent) + local L = FW.L + local f, stack = UI.CreateSection(parent, L.CODEX_PATH_HEADER) + local note = UI.CreateLabel(f, L.CODEX_PATH_NOTE, { size = 11, color = "textDim", justifyV = "TOP" }) + stack:Add(note, { fit = true, gap = 8 }) + local box = UI.CreateCheckbox(f, L.CODEX_PATH_SWITCH, function() return Path.Enabled() end, + function(on) Path.SetEnabled(on) end) + stack:Add(box, { fit = true, gap = 10 }) + local stats = UI.CreateLabel(f, "", { size = 11, color = "textDim", justifyV = "TOP" }) + stack:Add(stats, { fit = true, gap = 6, minHeight = 14 }) + f.box, f.stats = box, stats + return f + end, + refresh = function(frame) + frame.box:Refresh() + local st = Path.Stats() + frame.stats:SetText(string.format(FW.L.CODEX_PATH_STATS, st.cells, st.edges, st.drops, st.links)) + frame.stack:Layout() + end, + }) +end + +SLASH_FREEWOODPOS1 = "/fwpos" +SlashCmdList["FREEWOODPOS"] = function(msg) + msg = tostring(msg or ""):lower() + if msg == "aus" or msg == "off" then + Path.SetEnabled(false) + FW:Print("Pos: Aufzeichnung aus.") + elseif msg == "an" or msg == "on" then + Path.SetEnabled(true) + FW:Print("Pos: Aufzeichnung an.") + else + Path.Diag() + end +end diff --git a/CodexPathMap.lua b/CodexPathMap.lua new file mode 100644 index 0000000..a45a0d4 --- /dev/null +++ b/CodexPathMap.lua @@ -0,0 +1,556 @@ +-- Freewood-OctoWoW-Codex: Wegenetz auf der Weltkarte anzeigen (Dev-Reiter, /fwwege). +-- Ebenen: Wege (gruen), Faelle (rot), Gebietsuebergaenge (je Art eine Farbe), Luecken (gelb: bekannte Quest-Geber/-Abgaben und +-- Dienst-NPCs, bei denen noch kein Weg aufgezeichnet ist). Die Welt-Koordinaten des Wegenetzes werden mit drei Messpunkten +-- aus der Kartenschnittstelle in Kartenanteile umgerechnet (die Abbildung ist je Karte linear). + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex or not Codex.Path then + return +end + +local Path = Codex.Path +local PM = { on = false } +Codex.PathMap = PM + +local GRID = Codex.GRID or 200 +local CELL = Path.CELL or 10 +local BIN = 1 / 300 -- Karten-Anteil je Punkt (Raster der Anzeige) +local MAX_DOTS = 5000 +local GAP_RADIUS = 0.012 -- so nah muss ein Weg an einem bekannten Ort liegen, damit er nicht als Luecke zaehlt +local MAX_GAPS = 250 +local LAYER_LEVEL = 1400 +local PROBE = 100 -- Yards Abstand der Messpunkte fuer die Umrechnung + +local COLORS = { + w = { 0.30, 0.90, 0.40 }, -- Weg + f = { 0.75, 0.45, 1.00 }, -- Flugroute + r = { 1.00, 0.60, 0.15 }, -- fremder Weg (empfangen oder aus Startdaten) + d = { 1.00, 0.25, 0.20 }, -- Fall + g = { 1.00, 0.85, 0.10 }, -- Luecke + lw = { 0.20, 0.80, 1.00 }, -- Grenzuebergang + lt = { 0.90, 0.30, 0.90 }, -- Sprung (Zeppelin, Portal) + lf = { 0.60, 0.60, 1.00 }, -- Flugpunkt + li = { 1.00, 0.60, 0.10 }, -- Instanz + s = { 1.00, 1.00, 1.00 }, -- Ortspunkt (nur fuer Berechtigte) +} + +local function View() + local db = Codex.db + db.settings.pathView = db.settings.pathView or {} + return db.settings.pathView +end + +function PM.LayerOn(k) + local v = View()[k] + return v ~= false +end + +function PM.SetLayer(k, on) + View()[k] = on and true or false + PM.Refresh() +end + +------------------------------------------------------------------------ +-- Umrechnung Welt -> Karte +------------------------------------------------------------------------ + +local function Vec(a, b) + return _G.CreateVector2D and _G.CreateVector2D(a, b) or { x = a, y = b, GetXY = function(self) return self.x, self.y end } +end + +-- Kartenanteil zu einer Weltposition; first/second wie UnitPosition (erster, zweiter Rueckgabewert) +local function MapPosOf(world, first, second, mapID, swap) + local CM = _G.C_Map + if not (CM and CM.GetMapPosFromWorldPos) then return nil end + local vec = swap and Vec(second, first) or Vec(first, second) + local ok, id, pos = pcall(CM.GetMapPosFromWorldPos, world, vec, mapID) + if not ok or not pos or not pos.GetXY then return nil end + local ok2, x, y = pcall(pos.GetXY, pos) + if not ok2 or type(x) ~= "number" or type(y) ~= "number" then return nil end + return x, y +end + +local function WorldOfMap(mapID) + local CM = _G.C_Map + if not (CM and CM.GetWorldPosFromMapPos) then return nil end + local ok, cont = pcall(CM.GetWorldPosFromMapPos, mapID, Vec(0.5, 0.5)) + if ok and type(cont) == "number" then return cont end + return nil +end + +local function CellCentre(cx, cy) + -- Zellen wurden aus x = zweiter, y = erster Rueckgabewert von UnitPosition gebildet + return (cy + 0.5) * CELL, (cx + 0.5) * CELL -- first, second +end + +local function InMap(x, y) + return x and y and x >= 0 and x <= 1 and y >= 0 and y <= 1 +end + +-- Erste Zellen einer Welt (fuer die Messpunkte) +local function SampleCells(cells, n) + local out = {} + for key in pairs(cells) do + out[#out + 1] = key + if #out >= n then break end + end + return out +end + +-- Orientierung bestimmen: erst am Spieler (falls er auf der Karte steht), sonst nach der Zahl der Zellen, die auf die Karte fallen +local function DetectSwap(world, cells, mapID) + local db = Codex.db + local saved = db.settings.pathSwap + if type(saved) == "boolean" then return saved end + local CM = _G.C_Map + local pos = Path.Read and Path.Read() + if pos and pos.world == world and CM and CM.GetPlayerMapPosition then + local ok, mp = pcall(CM.GetPlayerMapPosition, mapID, "player") + if ok and mp and mp.GetXY then + local px, py = mp:GetXY() + local best, bestErr + for _, swap in ipairs({ false, true }) do + local x, y = MapPosOf(world, pos.y, pos.x, mapID, swap) + if x then + local err = math.abs(x - px) + math.abs(y - py) + if not bestErr or err < bestErr then best, bestErr = swap, err end + end + end + if best ~= nil and bestErr < 0.05 then + db.settings.pathSwap = best + return best + end + end + end + local score = { [false] = 0, [true] = 0 } + for _, key in ipairs(SampleCells(cells, 40)) do + local cx, cy = Path.KeyToCell(key) + local f, s = CellCentre(cx, cy) + for _, swap in ipairs({ false, true }) do + local x, y = MapPosOf(world, f, s, mapID, swap) + if InMap(x, y) then score[swap] = score[swap] + 1 end + end + end + return score[true] > score[false] +end + +-- Lineare Abbildung (first, second) -> (x, y) der Karte, aus drei Messpunkten +local function Fit(world, cells, mapID, swap) + for _, key in ipairs(SampleCells(cells, 60)) do + local cx, cy = Path.KeyToCell(key) + local f0, s0 = CellCentre(cx, cy) + local x0, y0 = MapPosOf(world, f0, s0, mapID, swap) + if InMap(x0, y0) then + local x1, y1 = MapPosOf(world, f0 + PROBE, s0, mapID, swap) + local x2, y2 = MapPosOf(world, f0, s0 + PROBE, mapID, swap) + if x1 and x2 then + local a, b, c, d = (x1 - x0) / PROBE, (x2 - x0) / PROBE, (y1 - y0) / PROBE, (y2 - y0) / PROBE + if a ~= 0 or b ~= 0 or c ~= 0 or d ~= 0 then + return function(f, s) + return x0 + (f - f0) * a + (s - s0) * b, y0 + (f - f0) * c + (s - s0) * d + end + end + end + end + end + return nil +end + +-- Ersatz, wenn keine der Stichprobenzellen auf der Karte liegt (kleine Karten wie Staedte): Mittelpunkt der Karte +-- in Weltkoordinaten erfragen und die Abbildung dort messen +local function FitFromMap(world, mapID, swap) + local CM = _G.C_Map + if not (CM and CM.GetWorldPosFromMapPos) then return nil end + local ok, cont, wp = pcall(CM.GetWorldPosFromMapPos, mapID, Vec(0.5, 0.5)) + if not ok or cont ~= world or not wp or not wp.GetXY then return nil end + local ok2, vx, vy = pcall(wp.GetXY, wp) + if not ok2 or type(vx) ~= "number" or type(vy) ~= "number" then return nil end + local f0, s0 + if swap then f0, s0 = vy, vx else f0, s0 = vx, vy end + local x0, y0 = MapPosOf(world, f0, s0, mapID, swap) + local x1, y1 = MapPosOf(world, f0 + PROBE, s0, mapID, swap) + local x2, y2 = MapPosOf(world, f0, s0 + PROBE, mapID, swap) + if not (x0 and x1 and x2) then return nil end + local a, b, c, d = (x1 - x0) / PROBE, (x2 - x0) / PROBE, (y1 - y0) / PROBE, (y2 - y0) / PROBE + if a == 0 and b == 0 and c == 0 and d == 0 then return nil end + return function(f, s) + return x0 + (f - f0) * a + (s - s0) * b, y0 + (f - f0) * c + (s - s0) * d + end +end + +------------------------------------------------------------------------ +-- Daten einer Karte +------------------------------------------------------------------------ + +-- Rueckgabe: { dots = { { x, y, kind, w } }, counts = { w, d, x, g }, swap }, oder nil, wenn nichts umgerechnet werden kann +function PM.Compute(mapID) + local db = Codex.db + if not (db and mapID) then return nil end + local res = { dots = {}, counts = { w = 0, d = 0, x = 0, g = 0 }, mapID = mapID } + local world = WorldOfMap(mapID) + local cells = world and Path.Cells(world) + local map + if cells and next(cells) then + res.swap = DetectSwap(world, cells, mapID) + map = Fit(world, cells, mapID, res.swap) or FitFromMap(world, mapID, res.swap) + end + local bins = {} + local function Bin(x, y, kind, w) + local bx, by = math.floor(x / BIN), math.floor(y / BIN) + local key = by * 100000 + bx + local b = bins[key] + if not b then b = { x = x, y = y, kind = kind, w = w }; bins[key] = b + elseif kind == "d" then b.kind = "d" end + return b + end + local pathHash = {} + local own = (db.path and db.path[world]) or {} + if map then + for key, mask in pairs(cells) do + local cx, cy = Path.KeyToCell(key) + local f, s = CellCentre(cx, cy) + local x, y = map(f, s) + if InMap(x, y) then + local isDrop = math.floor(mask / 256) % 256 > 0 + local mine = own[key] ~= nil + local b = Bin(x, y, isDrop and "d" or (mine and "w" or "r"), 1) + if b.kind == "r" and mine then b.kind = "w" end -- eigener Weg geht vor + if isDrop then res.counts.d = res.counts.d + 1 + elseif mine then res.counts.w = res.counts.w + 1 + else res.counts.r = (res.counts.r or 0) + 1 end + local hx, hy = math.floor(x / GAP_RADIUS), math.floor(y / GAP_RADIUS) + pathHash[hy * 100000 + hx] = true + end + end + end + -- Flugrouten dieser Karte: Strecke als Punktreihe, Beschriftung in der Mitte + local flights, tags = {}, {} + local TX = Codex.Taxi + if map and TX and TX.Routes then + for _, r in pairs(TX.Routes()) do + local na, nb = TX.Node(r.a), TX.Node(r.b) + if na and nb and na.w == world and r.p and #r.p >= 4 then + local pts = {} + if na.a then pts[#pts + 1] = { na.a, na.b } end + for i = 1, #r.p - 1, 2 do pts[#pts + 1] = { r.p[i], r.p[i + 1] } end + if nb.a then pts[#pts + 1] = { nb.a, nb.b } end + local xs, ys = {}, {} + for i, q in ipairs(pts) do xs[i], ys[i] = map(q[1], q[2]) end + local any = false + for i = 1, #pts - 1 do + local x1, y1, x2, y2 = xs[i], ys[i], xs[i + 1], ys[i + 1] + if x1 and x2 and (InMap(x1, y1) or InMap(x2, y2)) then + any = true + local steps = math.max(1, math.min(40, math.ceil(math.max(math.abs(x2 - x1), math.abs(y2 - y1)) / 0.004))) + for k = 0, steps - 1 do + local fx, fy = x1 + (x2 - x1) * k / steps, y1 + (y2 - y1) * k / steps + if InMap(fx, fy) then flights[#flights + 1] = { x = fx, y = fy, kind = "f", w = 4 } end + end + end + end + if any then + local mid = math.max(1, math.floor(#pts / 2)) + if xs[mid] and InMap(xs[mid], ys[mid]) then + tags[#tags + 1] = { x = xs[mid], y = ys[mid], + text = string.format("%s > %s %s %s", na.n or "?", nb.n or "?", TX.Duration(r.t), TX.Money(r.c)) } + end + end + end + end + end + res.counts.f = #tags + res.tags = tags + -- Luecken: bekannte Orte ohne Weg in der Naehe + local function NearPath(x, y) + local hx, hy = math.floor(x / GAP_RADIUS), math.floor(y / GAP_RADIUS) + for dy = -1, 1 do + for dx = -1, 1 do + if pathHash[(hy + dy) * 100000 + hx + dx] then return true end + end + end + return false + end + local gaps, seen = {}, {} + local function Gap(x, y) + if not x or #gaps >= MAX_GAPS or not InMap(x, y) or NearPath(x, y) then return end + local k = math.floor(x / GAP_RADIUS) * 100000 + math.floor(y / GAP_RADIUS) + if seen[k] then return end + seen[k] = true + gaps[#gaps + 1] = { x = x, y = y, kind = "g", w = 2 } + end + local V = Codex.V and Codex.V() + for _, q in pairs(V and V.quests or {}) do + for _, p in ipairs({ q.give, q.turn }) do + if type(p) == "table" and p.m == mapID then Gap(p.x, p.y) end + end + end + for _, u in pairs(V and V.units or {}) do + if u.ro and next(u.ro) and u.cells then + for key in pairs(u.cells) do + local m = math.floor(key / (GRID * GRID)) + if m == mapID then + local idx = key % (GRID * GRID) + Gap(((idx % GRID) + 0.5) / GRID, (math.floor(idx / GRID) + 0.5) / GRID) + break + end + end + end + end + res.counts.g = #gaps + -- Gebietsuebergaenge dieser Karte + local crossings = {} + local function AddLink(a, b, kinds) + for kind, e in pairs(kinds) do + for _, p in ipairs(e.p or {}) do + local key, w + if a == mapID then key, w = p.fk, p.fw elseif b == mapID then key, w = p.tk, p.tw end + if key and w == world and map then + local cx, cy = Path.KeyToCell(key) + local f, s = CellCentre(cx, cy) + local x, y = map(f, s) + if InMap(x, y) then crossings[#crossings + 1] = { x = x, y = y, kind = "l" .. kind, w = 3, to = a == mapID and b or a } end + end + end + end + end + for a, row in pairs(Path.Links()) do + for b, kinds in pairs(row) do + if a == mapID or b == mapID then AddLink(a, b, kinds) end + end + end + res.counts.x = #crossings + -- Ortspunkte dieser Karte (Instanz-Eingaenge, Aufstiege): nur fuer Berechtigte + local spotDots = {} + local SP = Codex.Spots + if SP and Codex.Trust and Codex.Trust.CanEdit() then + for _, r in ipairs(SP.All()) do + if r.p.m == mapID and InMap(r.p.x, r.p.y) then + spotDots[#spotDots + 1] = { x = r.p.x, y = r.p.y, kind = "s", w = 3, name = r.n, sk = r.k } + end + end + end + res.counts.s = #spotDots + -- Reihenfolge: Wege unten, dann Faelle, Luecken, Uebergaenge oben + local list = {} + for _, b in pairs(bins) do list[#list + 1] = b end + table.sort(list, function(p, q) + if p.kind ~= q.kind then + local rank = { r = 0, w = 1 } + local a, b2 = rank[p.kind] or 2, rank[q.kind] or 2 + return a < b2 + end + if p.y ~= q.y then return p.y < q.y end + return p.x < q.x + end) + for _, b in ipairs(list) do res.dots[#res.dots + 1] = b end + for _, g in ipairs(flights) do res.dots[#res.dots + 1] = g end + for _, g in ipairs(gaps) do res.dots[#res.dots + 1] = g end + for _, c in ipairs(crossings) do res.dots[#res.dots + 1] = c end + for _, c in ipairs(spotDots) do res.dots[#res.dots + 1] = c end + return res +end + +-- Karten, die Daten haben (fuer die Auswahl): { { map, n } } nach Zahl der Uebergaenge +function PM.Maps() + local db = Codex.db + local count = {} + for a, row in pairs(Path.Links()) do + for b, kinds in pairs(row) do + local n = 0 + for _, e in pairs(kinds) do n = n + e.n end + if a > 0 then count[a] = (count[a] or 0) + n end + if b > 0 then count[b] = (count[b] or 0) + n end + end + end + local cur = _G.C_Map and _G.C_Map.GetBestMapForUnit and select(2, pcall(_G.C_Map.GetBestMapForUnit, "player")) + if type(cur) == "number" then count[cur] = count[cur] or 0 end + local out = {} + for m, n in pairs(count) do out[#out + 1] = { map = m, n = n } end + table.sort(out, function(x, y) + if x.n ~= y.n then return x.n > y.n end + return x.map < y.map + end) + return out +end + +------------------------------------------------------------------------ +-- Anzeige auf der Weltkarte +------------------------------------------------------------------------ + +local layer, pool, label + +local API = Codex.MapAPI + +local function Canvas() + return API.Canvas() +end + +local function CurrentMapID() + return API.CurrentMapID(function() + local list = {} + for _, e in ipairs(PM.Maps()) do list[#list + 1] = e.map end + return list + end) +end + +local function Scale(frame) + local s = 1 + if frame and frame.GetEffectiveScale and _G.UIParent then + local a, b = frame:GetEffectiveScale(), _G.UIParent:GetEffectiveScale() + if type(a) == "number" and type(b) == "number" and a > 0 and b > 0 then s = a / b end + end + return s +end + +local function EnsureLayer() + if layer then return layer end + local wm = _G.WorldMapFrame + if not (wm and Canvas()) then return nil end + if API.Classic() then + local canvas = Canvas() + layer = CreateFrame("Frame", nil, canvas) + layer:SetAllPoints(canvas) + layer:SetFrameLevel((canvas:GetFrameLevel() or 1) + 15) + else + layer = CreateFrame("Frame", nil, wm) + layer:SetAllPoints(wm.ScrollContainer or wm) + layer:SetFrameLevel((wm:GetFrameLevel() or 1) + LAYER_LEVEL) + end + if wm.GetFrameStrata then layer:SetFrameStrata(wm:GetFrameStrata()) end + if layer.SetClipsChildren then layer:SetClipsChildren(true) end + pool = {} + label = layer:CreateFontString(nil, "OVERLAY", "GameFontNormal") + label:SetPoint("TOPLEFT", layer, "TOPLEFT", 12, API.Classic() and -42 or -12) + label:SetJustifyH("LEFT") + local acc, lastScale, lastMap = 0, nil, nil + layer:SetScript("OnUpdate", function(_, elapsed) + acc = acc + (elapsed or 0) + if acc < 0.3 then return end + acc = 0 + if not PM.on then return end + local sc, m = Scale(Canvas()), API.Signature() + if sc ~= lastScale or m ~= lastMap then + lastScale, lastMap = sc, m + PM.Refresh() + end + end) + return layer +end + +local function Dot(i) + local t = pool[i] + if not t then + t = layer:CreateTexture(nil, "OVERLAY") + pool[i] = t + end + return t +end + +function PM.Refresh() + if not layer then + if not PM.on then return end + EnsureLayer() + end + if not layer then return end + for _, t in ipairs(pool) do t:Hide() end + for _, t in ipairs(PM.tagPool or {}) do t:Hide() end + if not PM.on then + label:SetText("") + layer:Hide() + return + end + layer:Show() + local mapID = CurrentMapID() + local canvas = Canvas() + if not (mapID and canvas) then return end + local width, height = canvas:GetWidth() or 1, canvas:GetHeight() or 1 + if width < 2 or height < 2 then return end + local res = PM.Compute(mapID) + PM.last = res + local L = FW.L + if not res or (res.counts.w + (res.counts.f or 0) + (res.counts.r or 0) + res.counts.d + res.counts.x + res.counts.g + (res.counts.s or 0)) == 0 then + label:SetText(L.CODEX_PATHMAP_NONE) + return + end + label:SetText(string.format(L.CODEX_PATHMAP_LABEL, res.counts.w, res.counts.d, res.counts.x, res.counts.g)) + local scale, layerScale = Scale(canvas), Scale(layer) + local ratio = scale / layerScale + local n = 0 + for _, d in ipairs(res.dots) do + local show + if d.kind == "w" then show = PM.LayerOn("w") + elseif d.kind == "r" then show = PM.LayerOn("r") + elseif d.kind == "f" then show = PM.LayerOn("f") + elseif d.kind == "d" then show = PM.LayerOn("d") + elseif d.kind == "g" then show = PM.LayerOn("g") + elseif d.kind == "s" then show = PM.LayerOn("s") + else show = PM.LayerOn("x") end + if show then + n = n + 1 + if n > MAX_DOTS then break end + local t = Dot(n) + local px = (d.w == 1 and 3 or (d.w == 4 and 4 or d.w == 2 and 8 or (d.kind == "s" and 14 or 10))) / layerScale + local c = COLORS[d.kind] or COLORS.w + t:ClearAllPoints() + t:SetSize(px, px) + t:SetPoint("CENTER", canvas, "TOPLEFT", d.x * width * ratio, -d.y * height * ratio) + t:SetColorTexture(c[1], c[2], c[3], (d.kind == "w" or d.kind == "r") and 0.85 or 1) + t:Show() + end + end + -- Beschriftung der Flugrouten + if PM.LayerOn("f") and res.tags then + PM.tagPool = PM.tagPool or {} + for i, tg in ipairs(res.tags) do + if i > 20 then break end + local fs = PM.tagPool[i] + if not fs then + fs = layer:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + PM.tagPool[i] = fs + end + fs:ClearAllPoints() + fs:SetPoint("BOTTOM", canvas, "TOPLEFT", tg.x * width * ratio, -tg.y * height * ratio + 4) + fs:SetText(tg.text) + fs:SetTextColor(COLORS.f[1], COLORS.f[2], COLORS.f[3]) + fs:Show() + end + end +end + +-- Weltkarte oeffnen, auf eine Karte stellen (optional) und die Ebenen einblenden +function PM.Show(mapID) + PM.on = true + local wm = _G.WorldMapFrame + if wm then + if not wm:IsShown() then + if type(_G.ToggleWorldMap) == "function" then pcall(_G.ToggleWorldMap) elseif wm.Show then pcall(wm.Show, wm) end + end + if mapID then API.SetMap(mapID) end + if not PM.hooked then + PM.hooked = true + if wm.OnMapChanged then pcall(_G.hooksecurefunc, wm, "OnMapChanged", function() PM.Refresh() end) end + if wm.HookScript then wm:HookScript("OnShow", function() PM.Refresh() end) end + if API.Classic() and _G.hooksecurefunc and _G.SetMapZoom then + pcall(_G.hooksecurefunc, "SetMapZoom", function() PM.Refresh() end) + end + end + end + PM.Refresh() + FW:Fire("CODEX_PATHMAP_CHANGED") +end + +function PM.Hide() + PM.on = false + PM.Refresh() + FW:Fire("CODEX_PATHMAP_CHANGED") +end + +function PM.Toggle(mapID) + if PM.on then PM.Hide() else PM.Show(mapID) end +end + +SLASH_FREEWOODPATHMAP1 = "/fwwege" +SlashCmdList["FREEWOODPATHMAP"] = function() PM.Toggle() end diff --git a/CodexQuestProbe.lua b/CodexQuestProbe.lua new file mode 100644 index 0000000..c5598bc --- /dev/null +++ b/CodexQuestProbe.lua @@ -0,0 +1,215 @@ +-- Freewood-OctoWoW-Codex: Probe /fwquest +-- Prueft auf Octo, was der Server zu einer Quest-ID liefert (C_QuestLog.GetQuestDetails) und ob sich die darin +-- genannten Kreaturen, Objekte und Items ueber ihre ID benennen lassen. Nur Diagnose: schreibt in den Chat und nach +-- Codex.db.questProbe (SavedVariables, erst bei /reload oder Abmelden), veraendert keine Daten der Datenbank. +-- +-- /fwquest prueft ein paar bekannte Quests (7, 6, 3904, 1467) +-- /fwquest [...] prueft diese Quest-IDs +-- /fwquest go prueft ein Spielobjekt (C_GameObjectInfo) +-- /fwquest npc prueft eine Kreatur (C_CreatureInfo) + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Probe = {} +Codex.QuestProbe = Probe + +local WAIT = 3 + +local function Store() + local db = Codex.db + if not db then return nil end + db.questProbe = db.questProbe or {} + return db.questProbe +end + +local function Short(v) + local s = tostring(v) + s = s:gsub("|", "/"):gsub("\n", " ") + if #s > 120 then s = s:sub(1, 120) .. "..." end + return s +end + +-- Tabelle als Zeilen "pfad = wert" +local function Flatten(t, path, out, depth) + if depth > 3 then out[#out + 1] = path .. " = {...}" return end + local keys = {} + for k in pairs(t) do keys[#keys + 1] = k end + table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) + for _, k in ipairs(keys) do + local v = t[k] + local p = path == "" and tostring(k) or (path .. "." .. tostring(k)) + if type(v) == "table" then + Flatten(v, p, out, depth + 1) + else + out[#out + 1] = p .. " = " .. Short(v) + end + end +end + +local function Say(text) FW:Print("Quest-Probe: " .. text) end + +local function Log(rec, line) + rec[#rec + 1] = line + Say(line) +end + +local function Call(fn, a) + if type(fn) ~= "function" then return false, "fehlt" end + return pcall(fn, a) +end + +local function ItemName(id) + local CI = _G.C_Item + local get = (CI and CI.GetItemInfo) or _G.GetItemInfo + if not get then return nil end + local ok, name = pcall(get, id) + if ok and type(name) == "string" and name ~= "" then return name end + return nil +end + +local function CreatureName(id) + local CC = _G.C_CreatureInfo + if not (CC and CC.GetCreatureInfoByID) then return nil end + local ok, info = pcall(CC.GetCreatureInfoByID, id) + if ok and type(info) == "table" then + return tostring(info.name) .. " (Typ " .. tostring(info.type) .. ", Rang " .. tostring(info.rank) .. (info.subName and info.subName ~= "" and (", " .. info.subName) or "") .. ")" + end + return nil +end + +local function ObjectName(id) + local CG = _G.C_GameObjectInfo + if not (CG and CG.GetGameObjectInfoByID) then return nil end + local ok, info = pcall(CG.GetGameObjectInfoByID, id) + if ok and type(info) == "table" then + return tostring(info.name) .. " (Typ " .. tostring(info.type) .. ", Anzeige " .. tostring(info.displayID) .. ")" + end + return nil +end + +local function RequestNames(details) + local CC, CG, CI = _G.C_CreatureInfo, _G.C_GameObjectInfo, _G.C_Item + local function Req(fn, id) if fn then pcall(fn, id) end end + for _, r in ipairs(details.requirements or {}) do + if r.kind == "monster" then Req(CC and CC.RequestLoadCreatureByID, r.id) + elseif r.kind == "object" then Req(CG and CG.RequestLoadGameObjectByID, r.id) + elseif r.kind == "item" then Req(CI and CI.RequestLoadItemDataByID, r.id) end + end + for _, list in ipairs({ details.rewardItems or {}, details.choiceItems or {} }) do + for _, it in ipairs(list) do Req(CI and CI.RequestLoadItemDataByID, it.id) end + end + if (details.srcItemID or 0) > 0 then Req(CI and CI.RequestLoadItemDataByID, details.srcItemID) end +end + +local function ReportDetails(id, rec, d) + local lines = {} + Flatten(d, "", lines, 0) + Log(rec, "Quest " .. id .. ": " .. #lines .. " Felder") + for _, l in ipairs(lines) do rec[#rec + 1] = " " .. l end + Say(string.format("%s | Stufe %s | Typ %s | Geld %s | Ziele %d | feste Items %d | Wahl-Items %d | Ort %s", + Short(d.title), tostring(d.level), tostring(d.questType or "-"), tostring(d.rewardMoney), + #(d.requirements or {}), #(d.rewardItems or {}), #(d.choiceItems or {}), + d.poi and (tostring(d.poi.mapID) .. ":" .. tostring(d.poi.x) .. "," .. tostring(d.poi.y)) or "-")) +end + +local function ReportNames(id, rec, d) + for _, r in ipairs(d.requirements or {}) do + local name + if r.kind == "monster" then name = CreatureName(r.id) + elseif r.kind == "object" then name = ObjectName(r.id) + else name = ItemName(r.id) end + Log(rec, string.format(" Ziel %s %d x%s -> %s", r.kind, r.id, tostring(r.count), name or "kein Name (noch nicht geladen)")) + end + for _, def in ipairs({ { "feste Belohnung", d.rewardItems }, { "Wahl-Belohnung", d.choiceItems } }) do + for _, it in ipairs(def[2] or {}) do + Log(rec, string.format(" %s Item %d x%s -> %s", def[1], it.id, tostring(it.count), ItemName(it.id) or "kein Name")) + end + end + if (d.srcItemID or 0) > 0 then + Log(rec, string.format(" Start-Item %d -> %s", d.srcItemID, ItemName(d.srcItemID) or "kein Name")) + end +end + +function Probe.Quest(id) + local QL = _G.C_QuestLog + if not (QL and QL.GetQuestDetails and QL.RequestLoadQuestByID) then + Say("C_QuestLog.GetQuestDetails gibt es in dieser ClassicAPI-Version nicht.") + return + end + local st = Store() + if not st then return end + local rec = {} + st[id] = rec + local okC, cached = Call(QL.IsQuestDataCachedByID, id) + rec[#rec + 1] = "vorher im Zwischenspeicher: " .. tostring(okC and cached) + pcall(QL.RequestLoadQuestByID, id) + Say("Quest " .. id .. ": Antwort in " .. WAIT .. " Sekunden ...") + C_Timer.After(WAIT, function() + local ok, d = pcall(QL.GetQuestDetails, id) + if not (ok and type(d) == "table") then + Log(rec, "Quest " .. id .. ": keine Antwort (unbekannt oder noch nicht geladen)" .. ((not ok) and (" - Fehler: " .. Short(d)) or "")) + return + end + ReportDetails(id, rec, d) + local okN, n = Call(QL.GetNumQuestObjectives, id) + rec[#rec + 1] = "GetNumQuestObjectives: " .. tostring(okN and n or "-") + RequestNames(d) + C_Timer.After(WAIT, function() ReportNames(id, rec, d) end) + end) +end + +function Probe.Object(id) + local CG = _G.C_GameObjectInfo + if not (CG and CG.GetGameObjectInfoByID) then Say("C_GameObjectInfo gibt es nicht.") return end + local st = Store() + if not st then return end + local rec = {} + st["go" .. id] = rec + rec[#rec + 1] = "vorher: " .. tostring(ObjectName(id)) + if CG.RequestLoadGameObjectByID then pcall(CG.RequestLoadGameObjectByID, id) end + Say("Objekt " .. id .. ": Antwort in " .. WAIT .. " Sekunden ...") + C_Timer.After(WAIT, function() + Log(rec, "Objekt " .. id .. ": " .. tostring(ObjectName(id) or "keine Antwort")) + end) +end + +function Probe.Npc(id) + local CC = _G.C_CreatureInfo + if not (CC and CC.GetCreatureInfoByID) then Say("C_CreatureInfo gibt es nicht.") return end + local st = Store() + if not st then return end + local rec = {} + st["npc" .. id] = rec + rec[#rec + 1] = "vorher: " .. tostring(CreatureName(id)) + if CC.RequestLoadCreatureByID then pcall(CC.RequestLoadCreatureByID, id) end + Say("Kreatur " .. id .. ": Antwort in " .. WAIT .. " Sekunden ...") + C_Timer.After(WAIT, function() + Log(rec, "Kreatur " .. id .. ": " .. tostring(CreatureName(id) or "keine Antwort")) + end) +end + +function Probe.Command(msg) + msg = (msg or ""):lower():gsub("^%s+", ""):gsub("%s+$", "") + local go = msg:match("^go%s+(%d+)$") + if go then Probe.Object(tonumber(go)) return end + local npc = msg:match("^npc%s+(%d+)$") + if npc then Probe.Npc(tonumber(npc)) return end + local ids = {} + for n in msg:gmatch("%d+") do ids[#ids + 1] = tonumber(n) end + if #ids == 0 then ids = { 7, 6, 3904, 1467 } end + for i, id in ipairs(ids) do + if i > 8 then break end + local qid = id + C_Timer.After((i - 1) * (WAIT * 2 + 1), function() Probe.Quest(qid) end) + end + Say("Ergebnis steht auch in Codex.db.questProbe (SavedVariables nach /reload).") +end + +_G.SLASH_FWQUESTPROBE1 = "/fwquest" +SlashCmdList["FWQUESTPROBE"] = function(msg) Probe.Command(msg) end diff --git a/CodexRecipes.lua b/CodexRecipes.lua new file mode 100644 index 0000000..4642533 --- /dev/null +++ b/CodexRecipes.lua @@ -0,0 +1,333 @@ +-- Freewood-OctoWoW-Codex: Rezepte der Berufe +-- Lernt beim Oeffnen eines Berufsfensters jedes Rezept: Ergebnis-Item, Material mit Mengen und die Schwierigkeit +-- (orange/gelb/gruen/grau) bei deinem damaligen Skill. Zusammen mit den Lehrer-Daten (Skill-Anforderung, Preis) +-- weiss der Plan so, was ein Rezept braucht und was es bringt, auch bevor du es gelernt hast. +-- db.recipes[Beruf][Rezeptname] = { it = Ergebnis-Item, rg = { [Materialitem] = Anzahl }, +-- ob = { optimal|medium|easy|trivial = { kleinster, groesster Skill } }, t = Zeit } +-- Nur lokal; das Teilen mit anderen Spielern kommt spaeter. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local R = {} +NS.Recipes = R +Codex.Recipes = R + +local DIFFS = { optimal = true, medium = true, easy = true, trivial = true } +local MAX_RECIPES = 600 + +local function Store() + local db = Codex.db + if not db then return nil end + db.recipes = db.recipes or {} + return db.recipes +end + +local function ItemIDOf(link) + return type(link) == "string" and tonumber(link:match("item:(%d+)")) or nil +end + +-- Schwierigkeit aus Zahl (Enum: 0 optimal, 1 mittel, 2 leicht, 3 trivial) oder Text +local DIFF_NUM = { [0] = "optimal", [1] = "medium", [2] = "easy", [3] = "trivial" } +local function DiffOf(v) + if type(v) == "number" then return DIFF_NUM[v] end + if type(v) == "string" then + v = v:lower() + if DIFFS[v] then return v end + end + return nil +end + +local function Try(fn, ...) + if type(fn) ~= "function" then return nil end + local ok, a, b, c, d = pcall(fn, ...) + if ok then return a, b, c, d end + return nil +end + +-- Neue Berufsoberflaeche (C_TradeSkillUI): { profession, skill, max, list = { { name, item, diff, reag, learned } } } +local function SnapshotNew() + local T = _G.C_TradeSkillUI + if type(T) ~= "table" then return nil end + local base = Try(T.GetBaseProfessionInfo) + local name = type(base) == "table" and (base.professionName or base.parentProfessionName) or nil + local skill = type(base) == "table" and tonumber(base.skillLevel) or nil + local max = type(base) == "table" and tonumber(base.maxSkillLevel) or nil + if not name or name == "" then return nil end + local ids = Try(T.GetAllRecipeIDs) + if type(ids) ~= "table" or #ids == 0 then return nil end + local list = {} + for _, id in ipairs(ids) do + local info = Try(T.GetRecipeInfo, id) + if type(info) == "table" and type(info.name) == "string" then + local diff = DiffOf(info.relativeDifficulty) or DiffOf(info.difficulty) + local item + local link = Try(T.GetRecipeItemLink, id) + item = ItemIDOf(link) + local reag = {} + local schem = Try(T.GetRecipeSchematic, id, false) + if type(schem) == "table" then + item = item or tonumber(schem.outputItemID) + for _, slot in ipairs(schem.reagentSlotSchematics or {}) do + local first = slot.reagents and slot.reagents[1] + local rid = first and tonumber(first.itemID) + if rid and tonumber(slot.quantityRequired) then reag[rid] = tonumber(slot.quantityRequired) end + end + end + if not next(reag) then + local n = Try(T.GetRecipeNumReagents, id) or 0 + for j = 1, n do + local _, _, need = Try(T.GetRecipeReagentInfo, id, j) + local rid = ItemIDOf(Try(T.GetRecipeReagentItemLink, id, j)) + if rid and tonumber(need) then reag[rid] = tonumber(need) end + end + end + list[#list + 1] = { name = info.name, item = item, diff = diff or "optimal", reag = reag, + learned = info.learned ~= false, hasDiff = diff ~= nil, trivial = tonumber(info.maxTrivialLevel) } + end + end + return { profession = name, skill = skill or 0, max = max or 0, list = list } +end + +-- Alte Handwerks-Schnittstelle (GetTradeSkill...) +local function SnapshotOld() + if not (GetTradeSkillLine and GetNumTradeSkills and GetTradeSkillInfo) then return nil end + local ok, name, rank, maxRank = pcall(GetTradeSkillLine) + if not ok or type(name) ~= "string" or name == "" or name == "UNKNOWN" then return nil end + local list = {} + for i = 1, math.min(GetNumTradeSkills() or 0, MAX_RECIPES) do + local rname, rtype = GetTradeSkillInfo(i) + if type(rname) == "string" and DIFFS[rtype] then + local item = GetTradeSkillItemLink and ItemIDOf(GetTradeSkillItemLink(i)) or nil + local reag = {} + local nr = GetTradeSkillNumReagents and GetTradeSkillNumReagents(i) or 0 + for j = 1, nr do + local _, _, need = GetTradeSkillReagentInfo(i, j) + local rid = GetTradeSkillReagentItemLink and ItemIDOf(GetTradeSkillReagentItemLink(i, j)) or nil + if rid and tonumber(need) then reag[rid] = tonumber(need) end + end + list[#list + 1] = { name = rname, item = item, diff = rtype, reag = reag, learned = true, hasDiff = true } + end + end + return { profession = name, skill = tonumber(rank) or 0, max = tonumber(maxRank) or 0, list = list } +end + +-- Offenes Berufsfenster lesen: neue Oberflaeche zuerst, sonst die alte +function R.Snapshot() + return SnapshotNew() or SnapshotOld() +end + +function R.ReadOpenWindow() + local store = Store() + if not store then return false end + local snap = R.Snapshot() + if not snap then return false end + R.last = snap + local prof = store[snap.profession] + if not prof then prof = {}; store[snap.profession] = prof end + local n = 0 + for _ in pairs(prof) do n = n + 1 end + for _, r in ipairs(snap.list) do + local rec = prof[r.name] + if not rec then + if n >= MAX_RECIPES then break end + rec = { rg = {}, ob = {} } + prof[r.name] = rec + n = n + 1 + end + if r.item then rec.it = r.item end + if r.trivial and r.trivial > 0 then rec.tv = r.trivial end -- ab diesem Skill grau + for rid, need in pairs(r.reag) do rec.rg[rid] = need end + if r.learned and r.hasDiff then + local range = rec.ob[r.diff] + if range then + range[1], range[2] = math.min(range[1], snap.skill), math.max(range[2], snap.skill) + else + rec.ob[r.diff] = { snap.skill, snap.skill } + end + end + rec.t = time and time() or 0 + end + FW:Fire("CODEX_RECIPES_CHANGED", snap.profession) + return true, #snap.list +end + +local reading = false +local function OnTradeSkill() + if reading then return end + reading = true + local function Run() + reading = false + Codex.Safe(R.ReadOpenWindow) + end + if C_Timer and C_Timer.After then + C_Timer.After(0.5, Run) + else + Run() + end +end + +-- Diagnose fuer /fwberufdump: zeigt, was das Spiel zurueckgibt +function R.Dump() + local function Say(msg) print("|cffe0b84dFreewood Beruf:|r " .. tostring(msg)) end + local T = _G.C_TradeSkillUI + Say("C_TradeSkillUI: " .. tostring(type(T) == "table") .. ", alte Schnittstelle (GetTradeSkillLine): " .. tostring(type(GetTradeSkillLine) == "function")) + if type(T) == "table" then + local base = Try(T.GetBaseProfessionInfo) + if type(base) == "table" then + local keys = {} + for k, v in pairs(base) do keys[#keys + 1] = k .. "=" .. tostring(v) end + table.sort(keys) + Say("GetBaseProfessionInfo: " .. table.concat(keys, ", ")) + else + Say("GetBaseProfessionInfo: nichts (Berufsfenster offen?)") + end + local ids = Try(T.GetAllRecipeIDs) + Say("GetAllRecipeIDs: " .. (type(ids) == "table" and (#ids .. " Rezepte") or "nichts")) + local id = type(ids) == "table" and ids[1] + if id then + local info = Try(T.GetRecipeInfo, id) + if type(info) == "table" then + local keys = {} + for k, v in pairs(info) do + if type(v) ~= "table" then keys[#keys + 1] = k .. "=" .. tostring(v) end + end + table.sort(keys) + Say("GetRecipeInfo(" .. id .. "): " .. table.concat(keys, ", ")) + else + Say("GetRecipeInfo(" .. id .. "): nichts") + end + local schem = Try(T.GetRecipeSchematic, id, false) + if type(schem) == "table" then + local keys = {} + for k, v in pairs(schem) do keys[#keys + 1] = k .. "=" .. (type(v) == "table" and ("#" .. #v) or tostring(v)) end + table.sort(keys) + Say("GetRecipeSchematic: " .. table.concat(keys, ", ")) + else + Say("GetRecipeSchematic: nichts; NumReagents=" .. tostring(Try(T.GetRecipeNumReagents, id)) .. ", ItemLink=" .. tostring(Try(T.GetRecipeItemLink, id))) + end + end + end + local snap = R.Snapshot() + if snap then + local learned = 0 + for _, r in ipairs(snap.list) do if r.learned then learned = learned + 1 end end + Say(string.format("Auslesen: %s %d/%d, %d Rezepte (%d gelernt)", snap.profession, snap.skill, snap.max, #snap.list, learned)) + local r = snap.list[1] + if r then + local mats = {} + for rid, n in pairs(r.reag) do mats[#mats + 1] = rid .. "x" .. n end + Say(string.format("Beispiel: %s, Ergebnis-Item %s, Farbe %s, Material %s", r.name, tostring(r.item), tostring(r.diff), table.concat(mats, " "))) + end + else + Say("Auslesen: nichts. Bitte das Berufsfenster oeffnen und den Befehl erneut eingeben.") + end + local pn, rn = R.Count() + Say("In der Datenbank: " .. pn .. " Berufe, " .. rn .. " Rezepte") +end + +SLASH_FREEWOODRECIPEDUMP1 = "/fwberufdump" +SlashCmdList["FREEWOODRECIPEDUMP"] = function() R.Dump() end + +function R:Start() + if self.started then return end + self.started = true + for _, event in ipairs({ "TRADE_SKILL_SHOW", "TRADE_SKILL_UPDATE", "TRADE_SKILL_LIST_UPDATE", "TRADE_SKILL_DATA_SOURCE_CHANGED" }) do + FW:RegisterEvent(event, OnTradeSkill, R) + end +end + +function R:Stop() + for _, event in ipairs({ "TRADE_SKILL_SHOW", "TRADE_SKILL_UPDATE", "TRADE_SKILL_LIST_UPDATE", "TRADE_SKILL_DATA_SOURCE_CHANGED" }) do + FW:UnregisterEvent(event, R) + end + self.started = false +end + +function R.Get(profession) + local store = Store() + return store and store[profession] or {} +end + +function R.Count() + local store = Store() + local professions, recipes = 0, 0 + for _, prof in pairs(store or {}) do + professions = professions + 1 + for _ in pairs(prof) do recipes = recipes + 1 end + end + return professions, recipes +end + +-- Skill-Anforderung eines Rezepts: vom Lehrer (kleinster Wert), sonst kleinster Skill, bei dem es orange war +function R.ReqOf(profession, name) + local best + for _, u in pairs(Codex.V().units) do + if u.tr and u.ro and (u.ro.proftrainer or 0) > 0 then + for _, r in pairs(u.tr) do + if r.nm == name and r.sn == profession and tonumber(r.sr) and (not best or r.sr < best) then best = r.sr end + end + end + end + if best then return best, true end + local rec = R.Get(profession)[name] + local o = rec and rec.ob and rec.ob.optimal + if o then return o[1], false end + return nil +end + +-- Schwierigkeit bei gegebenem Skill: aus Beobachtungen, sonst geschaetzt (je 10 Skill pro Farbe). +-- Rueckgabe: "optimal" | "medium" | "easy" | "trivial" | "locked", und ob geschaetzt (true) oder beobachtet (false) +function R.DiffAt(profession, name, skill) + local rec = R.Get(profession)[name] + local ob = rec and rec.ob or {} + for diff, range in pairs(ob) do + if skill >= range[1] and skill <= range[2] then return diff, false end + end + local req = R.ReqOf(profession, name) + if not req and not next(ob) and not (rec and rec.tv) then return nil end + -- Schwellen: gelb, gruen, grau beginnen bei + local base = req or (ob.optimal and ob.optimal[1]) or 0 + local tv = rec and rec.tv + -- Ist die graue Schwelle (maxTrivialLevel) bekannt, liegen gruen und gelb je 10 Skill darunter + local gray = ob.trivial and ob.trivial[1] or tv or math.max(base + 30, ob.easy and ob.easy[2] + 1 or 0) + local green = ob.easy and ob.easy[1] or (tv and math.max(gray - 10, ob.medium and ob.medium[2] + 1 or 0)) or math.max(gray - 10, ob.medium and ob.medium[2] + 1 or 0) + local yellow = ob.medium and ob.medium[1] or math.max(green - 10, ob.optimal and ob.optimal[2] + 1 or 0) + if not req and not ob.optimal and tv then base = math.max(0, yellow - 10) end + local first = req or (ob.optimal and ob.optimal[1]) or 0 + if skill < first then return "locked", true end + if skill < yellow then return "optimal", true end + if skill < green then return "medium", true end + if skill < gray then return "easy", true end + return "trivial", true +end + +-- Rezepte beim Lehrer: Liste { name, profession, req, price, trainerID, rec = Rezept oder nil } +function R.TrainerRows() + local out, seen = {}, {} + for uid, u in pairs(Codex.V().units) do + if u.tr and u.ro and (u.ro.proftrainer or 0) > 0 then + for _, r in pairs(u.tr) do + if r.nm and r.sn then + local key = r.sn .. "\1" .. r.nm + local e = seen[key] + local price = Codex.ModePrice and Codex.ModePrice(r.pr) or nil + if not e then + e = { name = r.nm, profession = r.sn, req = tonumber(r.sr), price = price, trainerID = uid, + rec = R.Get(r.sn)[r.nm] } + seen[key] = e + out[#out + 1] = e + elseif price and (not e.price or price < e.price) then + e.price, e.trainerID = price, uid + end + end + end + end + end + return out +end diff --git a/CodexRoles.lua b/CodexRoles.lua new file mode 100644 index 0000000..3e40e1a --- /dev/null +++ b/CodexRoles.lua @@ -0,0 +1,1289 @@ +-- Freewood-OctoWoW-Codex: Rollen (Haendler, Lehrer, Flugmeister ...) +-- Lernt beim Ansprechen von NPCs und Objekten: +-- units[id].ro[rolle] = Anzahl Beobachtungen (vendor, repair, flight, innkeeper, stablemaster, spirithealer, +-- auctioneer, battlemaster, banker, classtrainer, proftrainer); objects[id].ro: mailbox +-- units[id].ti = Titelzeile ("Waffenschmied") +-- units[id].wa[itemID] = { pr = { [preis] = Anzahl }, st = Stapel, lim = 1 (begrenzt), ec = 1 (Tauschkosten) } +-- alle beobachteten Preise (Ruf und Talente senken Preise); Prozent = Preis / hoechster Preis +-- units[id].tr[schluessel] = { nm, rk, lv = Stufe, sn = Fertigkeit, sr = Fertigkeitswert, pr = { [kosten] = Anzahl } } +-- units[id].fl[ziel] = { [preis] = Anzahl } Flugziele, units[id].fn = eigener Flugpunkt +-- Der Ort (Spielerposition beim Ansprechen) landet wie bei allen Datensaetzen in cells. +-- Alles laeuft geschuetzt; unbekannte Ereignisse werden vom Spiel einfach nicht angeboten. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Util = FW.Util + +local Roles = {} +NS.Roles = Roles +Codex.Roles = Roles + +local MAX_WARES = 250 +local MAX_PRICES = 6 +local MAX_TRAIN = 160 +local MAX_DEST = 60 +local MAX_COUNT = 1000 +local DEBOUNCE = 5 + +Roles.events = {} -- Diagnose: wie oft kam welches Ereignis +Roles.gossip = {} -- Diagnose: gesehene Gespraechs-Arten +Roles.last = nil -- zuletzt erfasste Rolle (Diagnose) +Roles.trainerSources = {} -- Diagnose: woher die Zauber-IDs der Lehrer kamen + +local debounce = {} +local visit = {} -- pro Besuch: bereits gezaehlte Preise + +local function Now() + return (GetTime and GetTime()) or 0 +end + +local function Str(v, max) + return Codex.Str(v, max) +end + +local function Num(v) + if Util.IsSecret(v) or type(v) ~= "number" or v ~= v then + return nil + end + return v +end + +local function ItemIDFromLink(link) + link = Util.Clean(link) + if type(link) ~= "string" then + return nil + end + return tonumber(link:match("item:(%d+)")) +end + +------------------------------------------------------------------------ +-- Wer wird gerade angesprochen +------------------------------------------------------------------------ + +-- Rueckgabe: Art ("npc"/"object"), ID, Name, Einheit +local function Resolve(allowObject, objectOnly) + for _, unit in ipairs(objectOnly and {} or { "npc", "target" }) do + local guid = UnitGUID and UnitGUID(unit) + local kind, id = Codex.ParseGUID(guid) + if kind then + local name = UnitName and Str((UnitName(unit)), 60) or nil + return kind, id, name, unit + end + end + local o = Codex.lastObject + if allowObject and o and Now() - (o.t or 0) < 10 then + return "object", o.id, o.name, nil + end + return nil +end + +local function Entity(kind, id) + if kind == "npc" then + return Codex:Unit(id, true) + end + return Codex:Object(id, true) +end + +-- Titelzeile ("Waffenschmied") aus der zweiten Tooltip-Zeile; Stufen- und Fraktionszeilen zaehlen nicht +local function TitleFrom(text) + text = Str(text, 60) + if not text then + return nil + end + text = text:gsub("^<", ""):gsub(">$", "") + text = text:gsub("^%s+", ""):gsub("%s+$", "") + if text == "" or text:find("%d") or text:find("%?%?") or #text > 40 then + return nil + end + local level = _G.LEVEL + if type(level) == "string" and text:sub(1, #level) == level then + return nil + end + return text +end + +local function ReadTitle(unit) + if not unit or not (C_TooltipInfo and C_TooltipInfo.GetUnit) then + return nil + end + local ok, data = pcall(C_TooltipInfo.GetUnit, unit) + if not ok or type(data) ~= "table" or Util.IsSecret(data) then + return nil + end + if TooltipUtil and TooltipUtil.SurfaceArgs then + pcall(TooltipUtil.SurfaceArgs, data) + end + local line = type(data.lines) == "table" and data.lines[2] or nil + if type(line) ~= "table" then + return nil + end + return TitleFrom(line.leftText) +end + +------------------------------------------------------------------------ +-- Rolle vermerken +------------------------------------------------------------------------ + +-- Gibt die Einheit/das Objekt zurueck (oder nil), vermerkt Rolle, Namen, Titel und Ort (hoechstens alle 5 s je Rolle) +function Roles:Mark(role, allowObject) + local kind, id, name, unit = Resolve(allowObject, role == "mailbox") + if not kind then + return nil + end + if kind == "object" and role ~= "mailbox" then + return nil + end + if kind == "npc" and role == "mailbox" then + return nil + end + local ent = Entity(kind, id) + if name then + ent.n = name + end + if kind == "npc" then + local title = ReadTitle(unit) + if title then + ent.ti = title + end + end + local key = kind .. id .. role + local t = Now() + if not debounce[key] or t - debounce[key] > DEBOUNCE then + debounce[key] = t + ent.ro = ent.ro or {} + ent.ro[role] = math.min((ent.ro[role] or 0) + 1, MAX_COUNT) + Codex.AddCell(ent) + Roles.last = { role = role, kind = kind, id = id } + end + FW:Fire("CODEX_CHANGED", kind == "npc" and "unit" or "object", id) + return ent, kind, id +end + +------------------------------------------------------------------------ +-- Haendler +------------------------------------------------------------------------ + +local function AddPrice(prices, price) + if prices[price] then + prices[price] = math.min(prices[price] + 1, MAX_COUNT) + return + end + local n = 0 + for _ in pairs(prices) do + n = n + 1 + end + if n < MAX_PRICES then + prices[price] = 1 + end +end + +local function MerchantInfo(i) + local name, price, stack, avail, ec + -- Zuerst die native 1.12-Funktion (Name, Textur, Preis, Menge, Vorrat) + if GetMerchantItemInfo then + local ok, n, _, p, q, a = pcall(GetMerchantItemInfo, i) + if ok then + name, price, stack, avail = n, p, q, a + end + end + -- Luecken mit der Tabellen-Abfrage fuellen (falls vorhanden) + if (name == nil or price == nil) and C_MerchantFrame and C_MerchantFrame.GetItemInfo then + local ok, info = pcall(C_MerchantFrame.GetItemInfo, i) + if ok and type(info) == "table" and not Util.IsSecret(info) then + if name == nil then name = info.name end + if price == nil then price = info.price end + if stack == nil then stack = info.stackCount end + if avail == nil then avail = info.numAvailable end + ec = info.hasExtendedCost + end + end + if ec == nil and GetMerchantItemCostInfo then + local ok, count = pcall(GetMerchantItemCostInfo, i) + ec = ok and type(count) == "number" and count > 0 + end + return Str(name, 80), Num(price), Num(stack), Num(avail), ec == true +end + +local function MerchantItemID(i) + local link + if GetMerchantItemLink then + link = GetMerchantItemLink(i) + end + local id = ItemIDFromLink(link) + if id then + return id + end + if C_MerchantFrame and C_MerchantFrame.GetItemInfo then + local ok, info = pcall(C_MerchantFrame.GetItemInfo, i) + if ok and type(info) == "table" then + return Num(info.itemID) or ItemIDFromLink(info.link) + end + end + return nil +end + +-- Ruf-Anforderung einer Ware aus dem Tooltip des Haendlerfensters ("Benoetigt Fraktion - Stufe"). +-- Rueckgabe: Tabelle { f, s, ok } | false (kein Ruf noetig) | nil (Tooltip nicht lesbar) +local function ReqPattern() + local fmt = _G.ITEM_REQ_REPUTATION + if type(fmt) ~= "string" or not fmt:find("%%s") then return nil end + local esc = fmt:gsub("([%^%$%(%)%.%[%]%*%+%-%?])", "%%%1"):gsub("%%s", "(.+)") + return "^" .. esc .. "$" +end + +local function StandingIndex(text) + local low = tostring(text or ""):lower() + for i = 1, 8 do + for _, key in ipairs({ "FACTION_STANDING_LABEL" .. i, "FACTION_STANDING_LABEL" .. i .. "_FEMALE" }) do + local label = _G[key] + if type(label) == "string" and label:lower() == low then return i end + end + end + return nil +end + +local function IsRed(c) + return type(c) == "table" and (c.r or c[1] or 0) > 0.8 and (c.g or c[2] or 1) < 0.3 and (c.b or c[3] or 1) < 0.3 +end + +function Codex.ReputationRequirement(data) + if type(data) ~= "table" or type(data.lines) ~= "table" then return nil end + local pat = ReqPattern() or "^Requires (.+) %- (.+)$" + for _, line in ipairs(data.lines) do + local text = type(line) == "table" and Str(line.leftText, 120) + if text then + local fac, stand = text:match(pat) + if not fac then fac, stand = text:match("^Ben\195\182tigt (.+) %- (.+)$") end + if not fac then + -- Ersatz: " - " mit einer bekannten Ruf-Stufe am Ende + local f2, s2 = text:match("^(.-)%s+[%-\226\128\147]+%s+(.+)$") + if f2 and s2 and StandingIndex(s2) then fac, stand = f2, s2 end + end + if fac and stand then + local idx = StandingIndex(stand) + if idx then + return { f = fac:sub(1, 40), s = idx, ok = not IsRed(line.leftColor) } + end + end + end + end + return false +end + +local function MerchantTooltip(i) + if not (C_TooltipInfo and C_TooltipInfo.GetMerchantItem) then return nil end + local ok, data = pcall(C_TooltipInfo.GetMerchantItem, i) + if not ok or type(data) ~= "table" or Util.IsSecret(data) then return nil end + if TooltipUtil and TooltipUtil.SurfaceArgs then pcall(TooltipUtil.SurfaceArgs, data) end + return data +end + +-- /fwruf: zeigt fuer die ersten Waren des offenen Haendlers die Tooltip-Zeilen und was daraus gelesen wurde +function Roles.RepDiag() + if not (GetMerchantNumItems and (GetMerchantNumItems() or 0) > 0) then + FW:Print("Ruf: Bitte zuerst ein Haendlerfenster oeffnen.") + return + end + if not (C_TooltipInfo and C_TooltipInfo.GetMerchantItem) then + FW:Print("Ruf: C_TooltipInfo.GetMerchantItem gibt es hier nicht - Ruf-Zeilen koennen nicht gelesen werden.") + return + end + FW:Print("Ruf: Muster = " .. tostring(_G.ITEM_REQ_REPUTATION) .. " | Stufen: " .. tostring(_G.FACTION_STANDING_LABEL5) .. ", " .. tostring(_G.FACTION_STANDING_LABEL6)) + local shown = 0 + for i = 1, math.min(GetMerchantNumItems(), MAX_WARES) do + local tip = MerchantTooltip(i) + local rq = tip and Codex.ReputationRequirement(tip) + if rq or shown < 2 then + shown = shown + 1 + local texts = {} + for _, line in ipairs(tip and tip.lines or {}) do + if type(line) == "table" and type(line.leftText) == "string" then texts[#texts + 1] = line.leftText end + end + FW:Print(string.format("Ruf: Ware %d: %s -> %s", i, #texts > 0 and table.concat(texts, " | "):sub(1, 160) or "(kein Tooltip)", + rq and string.format("%s Stufe %d %s", rq.f, rq.s, rq.ok and "erfuellt" or "NICHT erfuellt") or (rq == false and "kein Ruf noetig" or "nicht lesbar"))) + end + if shown >= 8 then break end + end +end +SLASH_FREEWOODREPDIAG1 = "/fwruf" +SlashCmdList["FREEWOODREPDIAG"] = function() Roles.RepDiag() end + +function Roles:ScanMerchant() + local count = 0 + if GetMerchantNumItems then + count = GetMerchantNumItems() or 0 + elseif C_MerchantFrame and C_MerchantFrame.GetNumItems then + count = C_MerchantFrame.GetNumItems() or 0 + end + if Util.IsSecret(count) or type(count) ~= "number" or count <= 0 then + return + end + local ent, _, vid = self:Mark("vendor") + if not ent then + return + end + -- Fraktion des Haendlers (Horde / Allianz) merken + if vid and UnitFactionGroup then + local ok, group = pcall(UnitFactionGroup, "npc") + if ok and not Util.IsSecret(group) and (group == "Alliance" or group == "Horde") then + Codex.db.vfac = Codex.db.vfac or {} + Codex.db.vfac[vid] = group == "Alliance" and "A" or "H" + end + end + local wrq + if vid then + Codex.db.wrq = Codex.db.wrq or {} + wrq = Codex.db.wrq[vid] + end + if CanMerchantRepair and CanMerchantRepair() then + self:Mark("repair") + end + ent.wa = ent.wa or {} + local seen = visit.wares or {} + visit.wares = seen + local changed = false + for i = 1, math.min(count, MAX_WARES) do + local id = MerchantItemID(i) + local name, price, stack, avail, ec = MerchantInfo(i) + if i == 1 then + local lk = GetMerchantItemLink and GetMerchantItemLink(1) + Roles.diag = { count = count, id = id, name = name, price = price, link = lk and tostring(lk):gsub("|", "!") } + end + visit.rep = visit.rep or {} + if id and wrq ~= false and vid and not visit.rep[id] then + local tip = MerchantTooltip(i) + if tip then + visit.rep[id] = true -- Ruf-Tooltip je Besuch nur einmal lesen (MERCHANT_UPDATE kommt oft) + local rq = Codex.ReputationRequirement(tip) + Codex.db.wrq[vid] = Codex.db.wrq[vid] or {} + wrq = Codex.db.wrq[vid] + wrq.v = 1 + wrq[id] = rq or nil + end + end + if id and price then + local ware = ent.wa[id] + if not ware then + local n = 0 + for _ in pairs(ent.wa) do + n = n + 1 + end + if n < MAX_WARES then + ware = { pr = {} } + ent.wa[id] = ware + end + end + if ware then + Codex.NoteName(id, name) + if stack and stack > 1 then + ware.st = math.min(math.floor(stack), 1000) + end + if avail and avail >= 0 then + ware.lim = 1 + end + if ec then + ware.ec = 1 + end + local token = id .. ":" .. math.floor(price) + if not seen[token] then + seen[token] = true + AddPrice(ware.pr, math.floor(price)) + changed = true + end + end + end + end + if changed then + FW:Fire("CODEX_CHANGED", "unit", Roles.last and Roles.last.id) + end +end + +------------------------------------------------------------------------ +-- Flugmeister +------------------------------------------------------------------------ + +function Roles:ScanTaxi() + if not NumTaxiNodes then + return + end + local count = NumTaxiNodes() + if Util.IsSecret(count) or type(count) ~= "number" or count <= 0 then + return + end + local ent = self:Mark("flight") + if not ent then + return + end + ent.fl = ent.fl or {} + local seen = visit.taxi or {} + visit.taxi = seen + local nDest = 0 + for _ in pairs(ent.fl) do + nDest = nDest + 1 + end + for i = 1, math.min(count, 120) do + local name = TaxiNodeName and Str(TaxiNodeName(i), 60) + local kind = TaxiNodeGetType and TaxiNodeGetType(i) + if name and kind == "CURRENT" then + ent.fn = name + elseif name and (kind == "REACHABLE" or kind == nil) then + local cost = Num(TaxiNodeCost and TaxiNodeCost(i)) or 0 + cost = math.floor(cost) + local dest = ent.fl[name] + if not dest and nDest < MAX_DEST then + dest = {} + ent.fl[name] = dest + nDest = nDest + 1 + end + if dest and not seen[name .. ":" .. cost] then + seen[name .. ":" .. cost] = true + AddPrice(dest, cost) + end + end + end +end + +------------------------------------------------------------------------ +-- Lehrer +------------------------------------------------------------------------ + +-- Lehrer-Funktionen: erst die klassische Form, dann C_Trainer (Antworten als Liste oder als Tabelle) +local function TrainerCall(name, ...) + local fn = _G[name] + if type(fn) ~= "function" and C_Trainer and type(C_Trainer[name]) == "function" then + fn = C_Trainer[name] + end + if type(fn) ~= "function" then + return nil + end + local r = { pcall(fn, ...) } + if not r[1] then + return nil + end + table.remove(r, 1) + return r +end + +local function FirstNumber(r, field) + local v = r and r[1] + if type(v) == "table" then + v = v[field] or v.cost or v.moneyCost + end + return Num(v) +end + +local TRAINER_STATES = { available = true, used = true, unavailable = true, header = true, none = true } + +-- Rang einer Lehrerzeile aus dem Tooltip (rechte Seite der ersten Zeile, zum Beispiel "Rang 2") +-- Zauber-ID einer Lehrerzeile: Link der Zeile, Tabellenfeld oder Tooltip-Daten. Rueckgabe: ID, Quelle +local function TrainerSpell(i, info) + local r = TrainerCall("GetTrainerServiceItemLink", i) + local link = r and Util.Clean(r[1]) + if type(link) == "string" then + local id = tonumber(link:match("spell:(%d+)")) or tonumber(link:match("enchant:(%d+)")) + if id then + return id, "link" + end + end + if type(info) == "table" then + local id = Num(info.spellID) or Num(info.spellId) + if id and id > 0 then + return id, "info" + end + end + for _, fname in ipairs({ "GetTrainerServiceSpellID", "GetTrainerServiceSpell" }) do + local rr = TrainerCall(fname, i) + local id = rr and Num(rr[1]) + if id and id > 0 then + return id, fname + end + end + if C_TooltipInfo and C_TooltipInfo.GetTrainerService then + local ok, data = pcall(C_TooltipInfo.GetTrainerService, i) + if ok and type(data) == "table" and not Util.IsSecret(data) then + local id = Num(data.spellID) or Num(data.spellId) + if not id and Enum and Enum.TooltipDataType and data.type == Enum.TooltipDataType.Spell then + id = Num(data.id) + end + if id and id > 0 then + return id, "tooltip" + end + end + end + return nil +end + +local function SpellRank(id) + local text + if C_Spell and C_Spell.GetSpellSubtext then + local ok, v = pcall(C_Spell.GetSpellSubtext, id) + text = ok and v or nil + elseif GetSpellSubtext then + local ok, v = pcall(GetSpellSubtext, id) + text = ok and v or nil + end + return Str(text, 30) +end + +function Codex.SpellRank(id) + return SpellRank(id) +end + +function Codex.SpellIcon(id) + if C_Spell and C_Spell.GetSpellInfo then + local ok, info = pcall(C_Spell.GetSpellInfo, id) + if ok and type(info) == "table" and info.iconID then + return info.iconID + end + end + if GetSpellTexture then + local ok, v = pcall(GetSpellTexture, id) + if ok then + return v + end + end + return nil +end + +local function TrainerRank(i) + if not (C_TooltipInfo and C_TooltipInfo.GetTrainerService) then + return nil + end + local ok, data = pcall(C_TooltipInfo.GetTrainerService, i) + if not ok or type(data) ~= "table" or Util.IsSecret(data) then + return nil + end + if TooltipUtil and TooltipUtil.SurfaceArgs then + pcall(TooltipUtil.SurfaceArgs, data) + end + for n = 1, 3 do + local line = type(data.lines) == "table" and data.lines[n] or nil + if type(line) == "table" then + local text = Str(line.rightText, 30) + if text and text:find("%d") and #text <= 20 then + return (text:gsub("^%(", ""):gsub("%)$", "")) + end + end + end + return nil +end + +function Roles:ScanTrainer() + local trade = TrainerCall("IsTradeskillTrainer") + local role = (trade and trade[1] == true) and "proftrainer" or "classtrainer" + local ent = self:Mark(role) + if not ent then + return + end + local c = TrainerCall("GetNumTrainerServices") + local count = FirstNumber(c) + Roles.trainerDiag = string.format("%s: %s", tostring(role), tostring(count)) + if not count or count <= 0 then + return + end + ent.tr = ent.tr or {} + if Codex.LearnPlanReset then Codex.LearnPlanReset() end + -- Klasse des Spielers merken: damit gehoert der Lehrer auch dann zu einer Klasse, wenn der Titel unbekannt ist + if role == "classtrainer" and not ent.trc then + local token + if UnitClass then + local _, t = UnitClass("player") + token = t + end + if type(token) == "string" and not Util.IsSecret(token) and Codex.CLASS_TOKENS[token] then ent.trc = token end + end + for key, rec in pairs(ent.tr) do + if rec.rk and TRAINER_STATES[rec.rk] then + ent.tr[key] = nil -- aeltere Fehlerfassung: Zustand statt Rang + end + end + local seen = visit.train or {} + visit.train = seen + local n = 0 + for _ in pairs(ent.tr) do + n = n + 1 + end + for i = 1, math.min(count, 400) do + local r = TrainerCall("GetTrainerServiceInfo", i) or {} + local name, rank, category = r[1], r[2], r[3] + if type(name) == "table" then + local t = name + name, rank, category = t.name, t.subText or t.rank or t.subtext, t.category or t.state + end + -- Forever liefert (Name, Zustand) ohne Rang: Zustand erkennen, Rang woanders suchen + if type(rank) == "string" and TRAINER_STATES[rank] then + category, rank = rank, nil + end + name, rank = Str(name, 60), Str(rank, 30) + local sp, source + if name and category ~= "header" then + sp, source = TrainerSpell(i, r[1]) + if sp then + Roles.trainerSources[source] = (Roles.trainerSources[source] or 0) + 1 + rank = rank or SpellRank(sp) + end + if not rank then + rank = TrainerRank(i) + end + end + if name and category ~= "header" then + local lv = FirstNumber(TrainerCall("GetTrainerServiceLevelReq", i), "level") + lv = lv and lv > 0 and math.floor(lv) or nil + -- Schluessel: Zauber-ID, sonst Name mit Rang, sonst Name mit Stufe + local plain = name .. (rank and ("#" .. rank) or (lv and ("#L" .. lv)) or "") + local key = sp and ("s" .. sp) or plain + if sp then + ent.tr[plain] = nil -- aeltere Eintraege ohne Zauber-ID + ent.tr[name .. (lv and ("#L" .. lv) or "")] = nil + end + local rec = ent.tr[key] + if not rec and n < MAX_TRAIN then + rec = { nm = name, rk = rank, pr = {} } + ent.tr[key] = rec + n = n + 1 + end + if rec and sp then + rec.sp = sp + rec.rk = rank or rec.rk + end + if rec then + if lv then + rec.lv = lv + end + local sk = TrainerCall("GetTrainerServiceSkillReq", i) + if sk then + local sn, sr = Str(sk[1], 40), Num(sk[2]) + if sn then + rec.sn, rec.sr = sn, sr and math.floor(sr) or nil + end + end + local cost = FirstNumber(TrainerCall("GetTrainerServiceCost", i), "moneyCost") + if cost and not seen[key .. ":" .. cost] then + seen[key .. ":" .. cost] = true + AddPrice(rec.pr, math.floor(cost)) + end + end + end + end +end + +------------------------------------------------------------------------ +-- Gespraeche und allgemeine Fenster +------------------------------------------------------------------------ + +local GOSSIP_ROLE = { + binder = "innkeeper", spiritguide = "spirithealer", battlemaster = "battlemaster", banker = "banker", + auctioneer = "auctioneer", taxi = "flight", vendor = "vendor", stable = "stablemaster", stablemaster = "stablemaster", +} + +local function GossipKinds() + local kinds = {} + if GetGossipOptions then + local t = { GetGossipOptions() } + for i = 2, #t, 2 do + if type(t[i]) == "string" then + kinds[#kinds + 1] = t[i] + end + end + end + if #kinds == 0 and C_GossipInfo and C_GossipInfo.GetOptions then + local ok, list = pcall(C_GossipInfo.GetOptions) + if ok and type(list) == "table" then + local icons + if Enum and Enum.GossipOptionIcon then + icons = {} + for name, value in pairs(Enum.GossipOptionIcon) do + icons[value] = tostring(name):lower() + end + end + for _, opt in ipairs(list) do + if type(opt) == "table" then + local kind = type(opt.type) == "string" and opt.type or (icons and icons[opt.icon]) or nil + if kind then + kinds[#kinds + 1] = kind:lower() + end + end + end + end + end + return kinds +end + +function Roles:OnGossip() + local done = {} + for _, kind in ipairs(GossipKinds()) do + Roles.gossip[kind] = (Roles.gossip[kind] or 0) + 1 + local role = GOSSIP_ROLE[kind] + if not role then + for word, r in pairs(GOSSIP_ROLE) do + if kind:find(word, 1, true) then + role = r + end + end + end + if role and not done[role] then + done[role] = true + self:Mark(role) + end + end +end + +-- PLAYER_INTERACTION_MANAGER_FRAME_SHOW: Enum.PlayerInteractionType -> Rolle +local INTERACTION_ROLE = { + Banker = "banker", Auctioneer = "auctioneer", TaxiNode = "flight", StableMaster = "stablemaster", + SpiritHealer = "spirithealer", Binder = "innkeeper", BattleMaster = "battlemaster", MailInfo = "mailbox", + Merchant = "vendor", +} + +function Roles:OnInteraction(ty) + local names = Roles.interactionNames + if not names then + names = {} + if Enum and Enum.PlayerInteractionType then + for name, value in pairs(Enum.PlayerInteractionType) do + names[value] = name + end + end + Roles.interactionNames = names + end + local name = names[ty] + if name then + Roles.gossip["frame:" .. name] = (Roles.gossip["frame:" .. name] or 0) + 1 + end + if name == "Trainer" then + Roles.trainerOpen = true + visit.train = nil + self:ScanTrainer() + return + end + local role = name and INTERACTION_ROLE[name] + if role and role ~= "vendor" and role ~= "flight" then + self:Mark(role, role == "mailbox") + end +end + +------------------------------------------------------------------------ +-- Start / Stopp +------------------------------------------------------------------------ + +local SIMPLE = { + PET_STABLE_SHOW = "stablemaster", + AUCTION_HOUSE_SHOW = "auctioneer", + BANKFRAME_OPENED = "banker", + BATTLEFIELDS_SHOW = "battlemaster", + MAIL_SHOW = "mailbox", +} + +local EVENTS = { "MERCHANT_SHOW", "MERCHANT_UPDATE", "MERCHANT_CLOSED", "TAXIMAP_OPENED", "TRAINER_SHOW", "TRAINER_UPDATE", "TRAINER_CLOSED", + "GOSSIP_SHOW", "PLAYER_INTERACTION_MANAGER_FRAME_SHOW" } + +local function Handler(event) + return function(_, ...) + Roles.events[event] = (Roles.events[event] or 0) + 1 + if not (Codex.db and Codex.db.settings and Codex.db.settings.enabled) then + return + end + Codex.Safe(function(...) + if event == "MERCHANT_SHOW" then + visit.wares = nil + visit.rep = nil + Roles:ScanMerchant() + elseif event == "MERCHANT_UPDATE" then + Roles:ScanMerchant() + elseif event == "MERCHANT_CLOSED" then + visit.wares = nil + visit.rep = nil + elseif event == "TAXIMAP_OPENED" then + visit.taxi = nil + Roles:ScanTaxi() + elseif event == "TRAINER_SHOW" then + visit.train = nil + Roles.trainerOpen = true + Roles:ScanTrainer() + elseif event == "TRAINER_UPDATE" then + if Roles.trainerOpen then + Roles:ScanTrainer() + end + elseif event == "TRAINER_CLOSED" then + Roles.trainerOpen = false + elseif event == "GOSSIP_SHOW" then + Roles:OnGossip() + elseif event == "PLAYER_INTERACTION_MANAGER_FRAME_SHOW" then + Roles:OnInteraction((...)) + elseif SIMPLE[event] then + Roles:Mark(SIMPLE[event], event == "MAIL_SHOW") + end + end, ...) + end +end + +function Roles:Start() + if self.started then + return + end + self.started = true + self.registered = {} + local function reg(event) + if FW:RegisterEvent(event, Handler(event), Roles) then + self.registered[#self.registered + 1] = event + end + end + for _, event in ipairs(EVENTS) do + reg(event) + end + for event in pairs(SIMPLE) do + reg(event) + end +end + +function Roles:Stop() + for _, event in ipairs(self.registered or {}) do + FW:UnregisterEvent(event, Roles) + end + self.registered = {} + self.started = false +end + +-- Ausgabe fuer /fwcodex roles +function Roles:Debug() + local L = FW.L + local list = {} + for event, n in pairs(Roles.events) do + list[#list + 1] = event .. " x" .. n + end + table.sort(list) + FW:Print(string.format(L.CODEX_ROLES_EVENTS, #list > 0 and table.concat(list, ", ") or "-")) + local g = {} + for kind, n in pairs(Roles.gossip) do + g[#g + 1] = kind .. " x" .. n + end + table.sort(g) + FW:Print(string.format(L.CODEX_ROLES_GOSSIP, #g > 0 and table.concat(g, ", ") or "-")) + local counts = {} + for _, group in ipairs({ Codex.db.units, Codex.db.objects }) do + for _, e in pairs(group) do + for role in pairs(e.ro or {}) do + counts[role] = (counts[role] or 0) + 1 + end + end + end + local c = {} + for role, n in pairs(counts) do + c[#c + 1] = (L["CODEX_ROLE_" .. role] or role) .. " " .. n + end + table.sort(c) + FW:Print(string.format(L.CODEX_ROLES_COUNTS, #c > 0 and table.concat(c, ", ") or "-")) + if Roles.trainerDiag then + local src = {} + for k, v in pairs(Roles.trainerSources) do src[#src + 1] = k .. " x" .. v end + table.sort(src) + FW:Print("Trainer: " .. Roles.trainerDiag .. " | Zauber-IDs: " .. (#src > 0 and table.concat(src, ", ") or "keine")) + end + if Roles.last then + FW:Print(string.format(L.CODEX_ROLES_LAST, tostring(Roles.last.role), tostring(Roles.last.kind), tostring(Roles.last.id))) + end +end + +------------------------------------------------------------------------ +-- Lesen (fuer die Oberflaeche): eigene + geteilte Daten +------------------------------------------------------------------------ + +-- Dienste: wer so etwas anbietet, gilt ohne eigene Kampfdaten nicht als Kampfkreatur +local SERVICE = { + vendor = true, repair = true, flight = true, innkeeper = true, stablemaster = true, spirithealer = true, + auctioneer = true, battlemaster = true, banker = true, classtrainer = true, proftrainer = true, +} + +-- Rollen einer Kreatur/eines Objekts aus Live-Daten und Vorlage: { rolle = true|Vorlagewert } +-- kind = "units" | "objects". Zusaetzlich fuer Kreaturen: combat (Kampfkreatur), elite, boss +function Codex.RolesOf(kind, id) + local out = {} + local seedReady = Codex.Seed and Codex.Seed.Ready and Codex.Seed.Ready() + local seed = seedReady and Codex.Seed.Roles(kind, id) or nil + for role, v in pairs(seed or {}) do + out[role] = v + end + local e = Codex.V and Codex.V()[kind] and Codex.V()[kind][id] + if e then + for role in pairs(e.ro or {}) do + out[role] = true + end + end + if kind == "units" then + local fought = e and ((e.k or 0) > 0 or e.tt ~= nil or e.xp ~= nil or e.xm ~= nil or e.d ~= nil) + local class = e and e.c + local rank + local known = fought or class ~= nil + if seedReady then + local ent = Codex.Seed.Entity("units", id) + if ent then + rank = ent.rank + known = known or ent.l1 ~= nil + end + end + if class == "rare" or class == "rareelite" or rank == 4 then + out.rares = true + end + if class == "elite" or class == "rareelite" or class == "worldboss" or rank == 1 or rank == 2 or rank == 3 then + out.elite = true + end + if class == "worldboss" or rank == 3 then + out.boss = true + end + local service = false + for role in pairs(out) do + if SERVICE[role] then service = true end + end + if fought or (known and not service) then + out.combat = true + end + end + return out +end + +-- Rollen in fester Reihenfolge (Auswahlliste) +Roles.ORDER = { + units = { "combat", "elite", "boss", "vendor", "repair", "flight", "innkeeper", "stablemaster", "spirithealer", "auctioneer", + "battlemaster", "banker", "classtrainer", "proftrainer", "rares" }, + objects = { "mailbox", "meetingstone", "chests", "herbs", "mines", "fish" }, +} +-- Nur Auswahl, nicht in Listenzeilen und Details (Rang steht dort schon) +local FILTER_ONLY = { combat = true, elite = true, boss = true } + +function Codex.RoleList(kind, id) + local have = Codex.RolesOf(kind, id) + local list = {} + for _, role in ipairs(Roles.ORDER[kind] or {}) do + if have[role] and not FILTER_ONLY[role] then + list[#list + 1] = role + end + end + return list +end + +------------------------------------------------------------------------ +-- Lernplan: was lernt eine Klasse auf welcher Stufe (aus den Lehrer-Daten) +------------------------------------------------------------------------ + +-- Titelwoerter je Klasse (Fallback, falls die Klassennamen des Spiels nicht passen). Das laengste Wort gewinnt, +-- darum steht "teufelsjaeger" vor "jaeger" nicht im Weg. +local CLASS_WORDS = { + WARRIOR = { "krieger", "warrior", "guerrier", "guerrero", "guerreiro", "guerriero" }, + PALADIN = { "paladin", "paladín" }, + HUNTER = { "jäger", "jaeger", "hunter", "chasseur", "cazador", "caçador", "cacciatore" }, + ROGUE = { "schurke", "schurken", "rogue", "voleur", "pícaro", "ladino", "ladro" }, + PRIEST = { "priester", "priest", "prêtre", "sacerdote" }, + SHAMAN = { "schamane", "schamanen", "shaman", "chaman", "chamán", "xamã", "sciamano" }, + MAGE = { "magier", "magie", "mage", "mago" }, + WARLOCK = { "hexenmeister", "warlock", "démoniste", "brujo", "bruxo", "stregone" }, + DRUID = { "druide", "druiden", "druid", "druide", "druida" }, + DEMONHUNTER = { "teufelsjäger", "dämonenjäger", "demon hunter", "chasseur de démons", "cazador de demonios" }, + DEATHKNIGHT = { "todesritter", "death knight", "chevalier de la mort", "caballero de la muerte" }, + MONK = { "mönch", "monk", "moine", "monje", "monge", "monaco" }, + EVOKER = { "rufer", "evoker", "évocateur", "evocador" }, +} +Codex.CLASS_TOKENS = {} +for token in pairs(CLASS_WORDS) do Codex.CLASS_TOKENS[token] = true end + +-- Klasse eines Lehrers aus der Titelzeile ("Hexenmeisterlehrer" -> WARLOCK) +function Codex.ClassOfTitle(title) + if type(title) ~= "string" or title == "" then return nil end + local low = title:lower() + local best, bestLen + for token, words in pairs(CLASS_WORDS) do + for _, w in ipairs(words) do + if #w >= 4 and low:find(w, 1, true) and (not bestLen or #w > bestLen) then + best, bestLen = token, #w + end + end + end + for _, names in ipairs({ LOCALIZED_CLASS_NAMES_MALE or {}, LOCALIZED_CLASS_NAMES_FEMALE or {} }) do + for token, name in pairs(names) do + if type(name) == "string" and name ~= "" then + -- auch verkuerzte Formen: "Magier" -> "Magie" (Magielehrer), "Magierin" -> "Magier" + local full = name:lower() + local stems = { full, (full:gsub("in$", "")), (full:gsub("er$", "e")) } + for _, stem in ipairs(stems) do + if #stem >= 4 and low:find(stem, 1, true) and (not bestLen or #stem > bestLen) then + best, bestLen = token, #stem + end + end + end + end + end + return best +end + +-- Klasse eines Lehrers: aus dem Titel, sonst die Klasse des Spielers, der das Lehrerfenster geoeffnet hat +-- (Klassenlehrer anderer Klassen lassen sich im Spiel nicht oeffnen) +function Codex.ClassOfTrainer(u) + if type(u) ~= "table" or not u.tr then return nil end + local byTitle = Codex.ClassOfTitle(u.ti) + if byTitle then return byTitle end + if u.ro and (u.ro.classtrainer or 0) > 0 and Codex.CLASS_TOKENS[u.trc or ""] then return u.trc end + return nil +end + +function Codex.ModePrice(pr) + local price, count + for p, n in pairs(pr or {}) do + if not count or n > count or (n == count and p > price) then price, count = p, n end + end + return price +end + +local function ModePrice(pr) + local price, count + for p, n in pairs(pr or {}) do + if not count or n > count or (n == count and p > price) then price, count = p, n end + end + return price +end + +-- Lehrer fuer Begleiter-Fertigkeiten (Tierausbilder) +local function IsPetTrainer(u) + local t = tostring(u and u.ti or ""):lower() + return t:find("tierausbilder", 1, true) ~= nil or t:find("pet trainer", 1, true) ~= nil or t:find("tiertrainer", 1, true) ~= nil +end + +-- Der Plan wird aus allen Lehrerdaten gebaut (ca. 10 ms) und aendert sich selten: Zwischenspeicher mit kurzer Lebensdauer. +-- Daten-Aenderungen (Scan, Teilen, Bearbeiten) rufen Codex.LearnPlanReset() auf. +local planCache = {} +local PLAN_TTL = 20 +function Codex.LearnPlanReset() planCache = {} end + +local BuildLearnPlan + +-- Ergebnis: { levels = { [lv] = { lv, spells = {...}, cost, trainers = {id...} } }, order = { lv... } } +function Codex.LearnPlan(token) + local now = _G.GetTime and _G.GetTime() or 0 + local c = planCache[token] + if c and now >= c.t and now - c.t < PLAN_TTL then return c.plan end + local plan = BuildLearnPlan(token) + planCache[token] = { t = now, plan = plan } + return plan +end + +BuildLearnPlan = function(token) + local levels, order = {}, {} + local seen = {} + -- Manche Lehrer-Eintraege haben keine Stufe (nicht gelesen). Die Stufe steht aber meist bei einem anderen Lehrer derselben + -- Klasse: dieselbe Zauber-ID, sonst derselbe Name mit demselben Rang. + local lvOf = {} + for _, u in pairs(Codex.V().units) do + if u.tr and Codex.ClassOfTrainer(u) == token then + for _, r in pairs(u.tr) do + if r.lv then + if r.sp then lvOf["s" .. r.sp] = r.lv end + if r.nm then lvOf[r.nm .. "|" .. (r.rk or "")] = r.lv end + end + end + end + end + for id, u in pairs(Codex.V().units) do + if Codex.ClassOfTrainer(u) == token then + for key, r in pairs(u.tr) do + local lv = r.lv or (r.sp and lvOf["s" .. r.sp]) or lvOf[(r.nm or "") .. "|" .. (r.rk or "")] or 0 + local skey = lv .. "\1" .. (r.sp and ("s" .. r.sp) or key) + local sp = seen[skey] + if not sp then + sp = { nm = r.nm, sp = r.sp, rk = r.rk, lv = lv, pr = {}, trainers = {} } + seen[skey] = sp + local g = levels[lv] + if not g then + g = { lv = lv, spells = {}, trainers = {}, trainerSet = {}, cost = 0 } + levels[lv] = g + order[#order + 1] = lv + end + g.spells[#g.spells + 1] = sp + end + sp.rk = sp.rk or r.rk + sp.sp = sp.sp or r.sp + for p, n in pairs(r.pr or {}) do sp.pr[p] = (sp.pr[p] or 0) + n end + local g = levels[lv] + if IsPetTrainer(u) then sp.pet = true end + if not sp.trainers[id] then + sp.trainers[id] = true + if not g.trainerSet[id] then + g.trainerSet[id] = true + g.trainers[#g.trainers + 1] = id + end + end + end + end + end + table.sort(order) + for _, lv in ipairs(order) do + local g = levels[lv] + table.sort(g.spells, function(a, b) + if (a.nm or "") ~= (b.nm or "") then return (a.nm or "") < (b.nm or "") end + return (a.rk or "") < (b.rk or "") + end) + table.sort(g.trainers) + for _, sp in ipairs(g.spells) do + sp.cost = ModePrice(sp.pr) + g.cost = g.cost + (sp.cost or 0) + end + end + return { levels = levels, order = order } +end + +-- Klassen, fuer die Lehrer-Daten vorliegen +function Codex.LearnClasses() + local set, list = {}, {} + for _, u in pairs(Codex.V().units) do + local t = Codex.ClassOfTrainer(u) + if t and not set[t] then set[t] = true; list[#list + 1] = t end + end + table.sort(list) + return list +end + +-- Gesamtkosten aller Stufen in (von, bis] +function Codex.LearnCost(plan, from, to) + local sum = 0 + for _, lv in ipairs(plan.order) do + if lv > from and lv <= to then sum = sum + plan.levels[lv].cost end + end + return sum +end + +------------------------------------------------------------------------ +-- Favoriten und Notizen (nur lokal, wird nicht geteilt) +------------------------------------------------------------------------ + +local FAV_PREFIX = { quests = "q", units = "u", objects = "o", items = "i" } +local MAX_NOTE = 200 + +local function FavKey(kind, id) + local p = FAV_PREFIX[kind] + return (p and id ~= nil) and (p .. tostring(id)) or nil +end + +function Codex.FavSupported(kind) return FAV_PREFIX[kind] ~= nil end + +function Codex.GetFav(kind, id) + local key = FavKey(kind, id) + return key and Codex.db.fav and Codex.db.fav[key] or nil +end + +function Codex.IsFav(kind, id) + return Codex.GetFav(kind, id) ~= nil +end + +function Codex.SetFav(kind, id, on) + local key = FavKey(kind, id) + if not key then return false end + Codex.db.fav = Codex.db.fav or {} + if on then + Codex.db.fav[key] = Codex.db.fav[key] or { t = time and time() or 0 } + else + Codex.db.fav[key] = nil + end + return true +end + +function Codex.SetNote(kind, id, text) + local key = FavKey(kind, id) + if not key then return false end + -- Zeichen, die Farbcodes oder das Austauschformat stoeren wuerden, ersetzen + text = tostring(text or ""):gsub("[%c|]", " "):gsub("[;=]", ","):gsub("^%s+", ""):gsub("%s+$", "") + if #text > MAX_NOTE then text = text:sub(1, MAX_NOTE) end + Codex.db.fav = Codex.db.fav or {} + local e = Codex.db.fav[key] + if text == "" then + if e then e.note = nil end + return true + end + if not e then + e = { t = time and time() or 0 } + Codex.db.fav[key] = e + end + e.note = text + return true +end + +------------------------------------------------------------------------ +-- Dev-Notizen: Notizen zu Eintraegen (Quest, Kreatur, Objekt, Item), nur mit dem Modul Freewood-OctoWoW-Dev und +-- einer Rolle mit Bearbeitungsrecht. Sie liegen lokal beim Entwickler und werden nicht geteilt. +------------------------------------------------------------------------ + +local MAX_DEVNOTE = 500 + +function Codex.DevNoteSupported(kind) return FAV_PREFIX[kind] ~= nil end + +-- Darf dieser Charakter Dev-Notizen sehen und schreiben? +function Codex.CanDevNote() + return FW.Dev ~= nil and Codex.Trust ~= nil and Codex.Trust.CanEdit() and true or false +end + +function Codex.GetDevNote(kind, id) + local key = FavKey(kind, id) + local e = key and Codex.db.dnotes and Codex.db.dnotes[key] + return e and e.n or nil +end + +function Codex.SetDevNote(kind, id, text) + local key = FavKey(kind, id) + if not key or not Codex.CanDevNote() then return false end + text = tostring(text or ""):gsub("[%c|]", " "):gsub("^%s+", ""):gsub("%s+$", "") + if #text > MAX_DEVNOTE then text = text:sub(1, MAX_DEVNOTE) end + Codex.db.dnotes = Codex.db.dnotes or {} + if text == "" then + Codex.db.dnotes[key] = nil + else + Codex.db.dnotes[key] = { n = text, t = time and time() or 0 } + end + return true +end + +function Codex.DevNoteCount() + local n = 0 + for _ in pairs(Codex.db.dnotes or {}) do n = n + 1 end + return n +end + +-- Austausch: eine Zeile "FWFAV1;q123=Notiz;u55=;i12=Notiz" (Eintraege ohne Notiz haben nach = nichts) +function Codex.FavExport() + local keys = {} + for key in pairs(Codex.db.fav or {}) do keys[#keys + 1] = key end + table.sort(keys) + local parts = { "FWFAV1" } + for _, key in ipairs(keys) do + local e = Codex.db.fav[key] + parts[#parts + 1] = key .. "=" .. (e.note or "") + end + return table.concat(parts, ";"), #keys +end + +-- Ergaenzt vorhandene Favoriten; bestehende Notizen bleiben. Rueckgabe: neu hinzugekommen, Notizen uebernommen, ungueltig +function Codex.FavImport(text) + text = tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "") + if text:sub(1, 6) ~= "FWFAV1" then return nil end + Codex.db.fav = Codex.db.fav or {} + local added, notes, bad = 0, 0, 0 + for item in text:sub(8):gmatch("[^;]+") do + local key, note = item:match("^([quio]%d+)=(.*)$") + if key then + local e = Codex.db.fav[key] + if not e then + e = { t = time and time() or 0 } + Codex.db.fav[key] = e + added = added + 1 + end + if note ~= "" and not e.note then + e.note = note:sub(1, 200) + notes = notes + 1 + end + else + bad = bad + 1 + end + end + return added, notes, bad +end diff --git a/CodexSeed.lua b/CodexSeed.lua new file mode 100644 index 0000000..95a13f2 --- /dev/null +++ b/CodexSeed.lua @@ -0,0 +1,708 @@ +-- Freewood-OctoWoW-Codex: Vorlage (Startdaten aus Freewood-OctoWoW-CodexData, Vanilla) +-- Das Datenaddon ist "LoadOnDemand" und wird erst beim ersten Zugriff geladen. Alles hier ist nur eine Vorlage: +-- eigene und geteilte Live-Daten gehen immer vor. Die Daten liegen als kurze Zeichenketten vor und werden erst +-- beim Zugriff entpackt (und gemerkt). +-- +-- Formate (siehe tools/pf2seed.lua): +-- Quest "Stufe|Mindeststufe|Rassen|Klassen|Beruf|Vorquests|Start|Abgabe|Ziele" (Refs: "U123.O55.I6497") +-- Kreatur "Stufe|Fraktion|Rang|Gebiet:Zelle,Zelle;Gebiet:Zelle" (Zelle = x*100+y in 1 %) +-- Item "u=,...;o=,...;v,..." +-- Rollen meta[rolle] = "id:wert,id:wert" (negative IDs sind Objekte) + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS.Codex +if not FW or not Codex then + return +end + +local Util = FW.Util +local GRID = Codex.GRID + +local Seed = {} +Codex.Seed = Seed + +local DATA_ADDON = "Freewood-OctoWoW-CodexData" +local S -- FreewoodCodexSeed, sobald geladen +local failed = false + +local floor = math.floor + +-- Rollen: Reihenfolge der Anzeige +Seed.ROLES = { + units = { "vendor", "repair", "flight", "innkeeper", "stablemaster", "spirithealer", "auctioneer", "battlemaster", "banker", "rares" }, + objects = { "mailbox", "meetingstone", "chests", "herbs", "mines", "fish" }, +} + +------------------------------------------------------------------------ +-- Laden +------------------------------------------------------------------------ + +function Seed.Load() + if S then + return true + end + if failed then + return false + end + local g = _G.FreewoodCodexSeed + if not (g and g.ready) then + local load = (C_AddOns and C_AddOns.LoadAddOn) or LoadAddOn + if type(load) == "function" then + pcall(load, DATA_ADDON) + end + g = _G.FreewoodCodexSeed + end + if g and g.ready then + S = g + return true + end + failed = true + return false +end + +-- Laedt bei Bedarf (nur die Oberflaeche ruft das auf, damit der Ladevorgang beim Oeffnen des Tabs geschieht) +function Seed.Disabled() + local db = Codex.db + return db and db.settings and db.settings.noSeed == true or false +end + +function Seed.Available() + if Seed.Disabled() then return false end + return Seed.Load() +end + +-- Nur pruefen, ob schon geladen ist, ohne zu laden (fuer Code, der waehrend des Spielens laeuft) +function Seed.Ready() + return S ~= nil and not Seed.Disabled() +end + +-- fuer Tests: Zustand zuruecksetzen +local caches +function Seed.Reset() + S, failed = nil, false + caches = { quest = {}, unit = {}, object = {}, item = {}, lists = {}, hay = {} } + Seed.nextIdx, Seed.rev, Seed.roleIdx, Seed.areaMap = nil, nil, nil, nil +end +Seed.Reset() + +local function Lang() + return Codex.Lang() +end + +------------------------------------------------------------------------ +-- Kleine Helfer +------------------------------------------------------------------------ + +local function Fields(s, n) + local out, pos = {}, 1 + for i = 1, n do + local e = s:find("|", pos, true) + if e then + out[i] = s:sub(pos, e - 1) + pos = e + 1 + else + out[i] = s:sub(pos) + pos = #s + 2 + end + end + return out +end + +local function Nums(s) + local list = {} + for n in (s or ""):gmatch("%d+") do + list[#list + 1] = tonumber(n) + end + return list +end + +local KIND_OF = { U = "units", O = "objects", I = "items" } + +local function Refs(s) + local list = {} + for k, id in (s or ""):gmatch("(%a)(%d+)") do + list[#list + 1] = { kind = KIND_OF[k], id = tonumber(id) } + end + return list +end + +local function AreaMap() + if Seed.areaMap then + return Seed.areaMap + end + local map = {} + for a, m in (S.areas or ""):gmatch("(%d+)=(%d+)") do + map[tonumber(a)] = tonumber(m) + end + Seed.areaMap = map + return map +end + +local function Pick(de, en) + if Lang() == "en" then + return en or de + end + return de or en +end + +------------------------------------------------------------------------ +-- Quests +------------------------------------------------------------------------ + +function Seed.Quest(id) + local c = caches.quest[id] + if c ~= nil then + return c or nil + end + if not Seed.Load() then + return nil + end + local s = S.q[id] + if not s then + caches.quest[id] = false + return nil + end + local f = Fields(s, 9) + local q = { + id = id, lvl = tonumber(f[1]), min = tonumber(f[2]), race = tonumber(f[3]), class = tonumber(f[4]), + skill = tonumber(f[5]), pre = Nums(f[6]), start = Refs(f[7]), stop = Refs(f[8]), obj = Refs(f[9]), + } + caches.quest[id] = q + return q +end + +function Seed.QuestTitle(id) + if not Seed.Load() then + return nil + end + return Pick(S.qd[id], S.qe[id]) +end + +-- Folgequests: alle Quests, die diese als Vorquest haben +function Seed.NextQuests(id) + if not Seed.Load() then + return {} + end + if not Seed.nextIdx then + local idx = {} + for qid, s in pairs(S.q) do + local pre = Fields(s, 6)[6] + for p in pre:gmatch("%d+") do + local n = tonumber(p) + local l = idx[n] + if not l then + l = {} + idx[n] = l + end + l[#l + 1] = qid + end + end + for _, l in pairs(idx) do + table.sort(l) + end + Seed.nextIdx = idx + end + return Seed.nextIdx[id] or {} +end + +-- Position in der Questkette: pos, total (zum Beispiel 2, 4), nil ausserhalb einer Kette. +-- Bei Verzweigungen zaehlt der laengste Weg vor und nach der Quest. +local chainMemo = {} +local function ChainLen(id, dir, depth) + local key = dir .. id + local c = chainMemo[key] + if c then + return c + end + if depth > 40 then + return 1 + end + local best = 0 + local list + if dir == "u" then + local q = Seed.Quest(id) + list = q and q.pre or {} + else + list = Seed.NextQuests(id) + end + for _, other in ipairs(list) do + if other ~= id then + best = math.max(best, ChainLen(other, dir, depth + 1)) + end + end + chainMemo[key] = best + 1 + return best + 1 +end + +function Seed.ChainPos(id) + if not Seed.Quest(id) then + return nil + end + local up, down = ChainLen(id, "u", 0), ChainLen(id, "d", 0) + if up + down <= 2 then + return nil + end + return up, up + down - 1 +end + +-- Stellung in einer Questkette: "start" (hat Folgequests, keine Vorquest), "mid", "end" (nur Vorquests) +-- oder nil (gehoert zu keiner Kette) +function Seed.ChainRole(id) + local q = Seed.Quest(id) + if not q then + return nil + end + -- manche Quests nennen sich selbst als Vorquest: das zaehlt nicht als Kette + local hasPre, hasNext = false, false + for _, p in ipairs(q.pre or {}) do + if p ~= id then hasPre = true break end + end + for _, n in ipairs(Seed.NextQuests(id)) do + if n ~= id then hasNext = true break end + end + if hasPre and hasNext then return "mid" end + if hasNext then return "start" end + if hasPre then return "end" end + return nil +end + +------------------------------------------------------------------------ +-- Kreaturen und Objekte +------------------------------------------------------------------------ + +local function EntityData(kind) + return kind == "units" and S.u or S.o +end + +-- Rueckgabe: { id, l1, l2, fac, rank, spots, cells } +function Seed.Entity(kind, id) + local cache = kind == "units" and caches.unit or caches.object + local c = cache[id] + if c ~= nil then + return c or nil + end + if not Seed.Load() then + return nil + end + local s = EntityData(kind)[id] + if not s then + cache[id] = false + return nil + end + local f = Fields(s, 4) + local l1, l2 = f[1]:match("^(%d+)%-?(%d*)$") + if l1 and l2 == "" then + l2 = l1 + end + local e = { + id = id, l1 = tonumber(l1), l2 = tonumber(l2), fac = f[2] ~= "" and f[2] or nil, + rank = tonumber(f[3]), spots = f[4], + } + cache[id] = e + return e +end + +function Seed.Name(kind, id) + if not Seed.Load() then + return nil + end + if kind == "units" then + return Pick(S.ud[id], S.ue[id]) + elseif kind == "objects" then + return Pick(S.od[id], S.oe[id]) + elseif kind == "items" then + return Pick(S.id[id], S.ie[id]) + end + return nil +end + +-- Zellen wie im Codex: { [Karte*GRID^2 + y*GRID + x] = 1 } +function Seed.Cells(kind, id) + local e = Seed.Entity(kind, id) + if not e then + return {} + end + if e.cells then + return e.cells + end + local cells = {} + local areas = AreaMap() + for area, list in e.spots:gmatch("(%d+):([%d,]+)") do + local m = areas[tonumber(area)] + if m then + for c in list:gmatch("%d+") do + c = tonumber(c) + local cx = math.min(GRID - 1, floor(c / 100) * 2 + 1) + local cy = math.min(GRID - 1, (c % 100) * 2 + 1) + cells[m * GRID * GRID + cy * GRID + cx] = 1 + end + end + end + e.cells = cells + return cells +end + +-- Erste Fundstelle: Karte, x, y (Anteile 0..1) +function Seed.FirstSpot(kind, id) + local e = Seed.Entity(kind, id) + if not e then + return nil + end + local area, list = e.spots:match("(%d+):([%d,]+)") + local m = area and AreaMap()[tonumber(area)] + local c = list and tonumber(list:match("%d+")) + if not (m and c) then + return nil + end + return m, (floor(c / 100) + 0.5) / 100, ((c % 100) + 0.5) / 100 +end + +-- Gebiet einer Kreatur / eines Objekts (Karten-ID der ersten Fundstelle) +function Seed.MapOf(kind, id) + local m = Seed.FirstSpot(kind, id) + return m +end + +-- Kleinster Abstand (Kartenanteil) einer Zellenmenge zum Spieler auf der aktuellen Karte, sonst nil +function Seed.Distance(cells, loc) + loc = loc or (Util.GetPlayerLocation and Util.GetPlayerLocation()) + if not (loc and loc.mapID and loc.x and loc.y) then + return nil + end + local best + local g2 = GRID * GRID + for key in pairs(cells or {}) do + if floor(key / g2) == loc.mapID then + local idx = key % g2 + local dx = ((idx % GRID) + 0.5) / GRID - loc.x + local dy = (floor(idx / GRID) + 0.5) / GRID - loc.y + local d = dx * dx + dy * dy + if not best or d < best then + best = d + end + end + end + return best and math.sqrt(best) or nil +end + +------------------------------------------------------------------------ +-- Items: Quellen +------------------------------------------------------------------------ + +-- Rueckgabe: { u = { { id, chance } }, o = { { id, chance } }, v = { haendlerID } } +function Seed.Item(id) + local c = caches.item[id] + if c ~= nil then + return c or nil + end + if not Seed.Load() then + return nil + end + local s = S.i[id] + if not s then + caches.item[id] = false + return nil + end + local it = { u = {}, o = {}, v = {} } + for part in s:gmatch("[^;]+") do + local tag, body = part:sub(1, 1), part:sub(2) + if tag == "u" or tag == "o" then + for k, v in body:gmatch("(%d+)=([%d%.]+)") do + local t = it[tag] + t[#t + 1] = { id = tonumber(k), chance = tonumber(v) } + end + elseif tag == "v" then + it.v = Nums(body) + end + end + caches.item[id] = it + return it +end + +function Seed.ItemHasVendor(id) + if not Seed.Load() then + return false + end + local s = S.i[id] + return s ~= nil and (s:find(";v", 1, true) ~= nil or s:sub(1, 1) == "v") +end + +local function Reverse() + if Seed.rev then + return Seed.rev + end + -- flache Listen { Item, Chance, Item, Chance ... } sparen Speicher + local rev = { u = {}, o = {}, v = {} } + for itemID, s in pairs(S.i) do + for part in s:gmatch("[^;]+") do + local tag, body = part:sub(1, 1), part:sub(2) + if tag == "u" or tag == "o" then + local t = rev[tag] + for k, v in body:gmatch("(%d+)=([%d%.]+)") do + local key = tonumber(k) + local l = t[key] + if not l then + l = {} + t[key] = l + end + l[#l + 1] = itemID + l[#l + 1] = tonumber(v) + end + elseif tag == "v" then + for n in body:gmatch("%d+") do + local key = tonumber(n) + local l = rev.v[key] + if not l then + l = {} + rev.v[key] = l + end + l[#l + 1] = itemID + end + end + end + end + Seed.rev = rev + return rev +end + +-- Beute einer Kreatur / eines Objekts laut Vorlage: { { item, chance (Prozent) } }, nach Chance sortiert +function Seed.Drops(kind, id) + if not Seed.Load() then + return {} + end + local flat = Reverse()[kind == "units" and "u" or "o"][id] + local list = {} + for i = 1, #(flat or {}), 2 do + list[#list + 1] = { item = flat[i], chance = flat[i + 1] } + end + table.sort(list, function(a, b) + if a.chance ~= b.chance then return a.chance > b.chance end + return a.item < b.item + end) + return list +end + +-- Waren eines Haendlers: { itemID } +function Seed.VendorItems(id) + if not Seed.Load() then + return {} + end + local list = {} + for i, itemID in ipairs(Reverse().v[id] or {}) do + list[i] = itemID + end + table.sort(list) + return list +end + +------------------------------------------------------------------------ +-- Quests je Kreatur / Objekt (Geber, Abgabe, Ziel) +------------------------------------------------------------------------ + +function Seed.QuestsOf(kind, id) + if not Seed.Load() then + return {}, {}, {} + end + if not Seed.questRev then + local rev = { give = {}, turn = {}, target = {} } + local function Add(t, key, qid) + local l = t[key] + if not l then + l = {} + t[key] = l + end + if l[#l] ~= qid then + l[#l + 1] = qid + end + end + for qid in pairs(S.q) do + local q = Seed.Quest(qid) + for _, r in ipairs(q.start) do + if r.kind ~= "items" then Add(rev.give, r.kind .. r.id, qid) end + end + for _, r in ipairs(q.stop) do + if r.kind ~= "items" then Add(rev.turn, r.kind .. r.id, qid) end + end + for _, r in ipairs(q.obj) do + if r.kind ~= "items" then Add(rev.target, r.kind .. r.id, qid) end + end + end + Seed.questRev = rev + end + local key = kind .. id + local rev = Seed.questRev + return rev.give[key] or {}, rev.turn[key] or {}, rev.target[key] or {} +end + +------------------------------------------------------------------------ +-- Rollen (Haendler, Flugmeister ...) +------------------------------------------------------------------------ + +local function RoleIndex() + if Seed.roleIdx then + return Seed.roleIdx + end + local idx = { units = {}, objects = {}, ids = {} } + for _, group in ipairs({ Seed.ROLES.units, Seed.ROLES.objects }) do + for _, role in ipairs(group) do + local list = {} + for k, v in (S.meta[role] or ""):gmatch("(%-?%d+):([^,]*)") do + local n = tonumber(k) + local kind = n > 0 and "units" or "objects" + n = math.abs(n) + local t = idx[kind][n] + if not t then + t = {} + idx[kind][n] = t + end + t[role] = v ~= "" and v or true + list[#list + 1] = { kind = kind, id = n } + end + idx.ids[role] = list + end + end + Seed.roleIdx = idx + return idx +end + +-- Rollen einer Kreatur / eines Objekts: { vendor = "H", herbs = 280, ... } oder nil +function Seed.Roles(kind, id) + if not Seed.Load() then + return nil + end + return RoleIndex()[kind][id] +end + +-- Rollen als Liste in fester Reihenfolge +function Seed.RoleList(kind, id) + local roles = Seed.Roles(kind, id) + if not roles then + return {} + end + local list = {} + for _, role in ipairs(Seed.ROLES[kind]) do + if roles[role] then + list[#list + 1] = role + end + end + return list +end + +function Seed.RoleIDs(role) + if not Seed.Load() then + return {} + end + return RoleIndex().ids[role] or {} +end + +-- Naechste Kreaturen / Objekte mit einer Rolle auf der aktuellen Karte: { { kind, id, dist, m, x, y } } +function Seed.Nearest(role, limit, faction) + local loc = Util.GetPlayerLocation and Util.GetPlayerLocation() + local list = {} + if not (loc and loc.mapID and loc.x and loc.y) or not Seed.Load() then + return list + end + local side = faction or (UnitFactionGroup and select(1, UnitFactionGroup("player"))) or nil + local letter = side == "Alliance" and "A" or side == "Horde" and "H" or nil + for _, ref in ipairs(Seed.RoleIDs(role)) do + local e = Seed.Entity(ref.kind, ref.id) + local roles = Seed.Roles(ref.kind, ref.id) + local value = roles and roles[role] + local fac = (type(value) == "string" and value:match("^[AH]+$")) or (e and e.fac) + if e and (not letter or not fac or fac:find(letter, 1, true)) then + local d = Seed.Distance(Seed.Cells(ref.kind, ref.id)) + if d then + local best, bm, bx, by + local g2 = GRID * GRID + for key in pairs(Seed.Cells(ref.kind, ref.id)) do + if floor(key / g2) == loc.mapID then + local idx = key % g2 + local x, y = ((idx % GRID) + 0.5) / GRID, (floor(idx / GRID) + 0.5) / GRID + local dd = (x - loc.x) ^ 2 + (y - loc.y) ^ 2 + if not best or dd < best then + best, bm, bx, by = dd, loc.mapID, x, y + end + end + end + list[#list + 1] = { kind = ref.kind, id = ref.id, dist = d, m = bm, x = bx, y = by } + end + end + end + table.sort(list, function(a, b) + if a.dist ~= b.dist then return a.dist < b.dist end + return a.id < b.id + end) + if limit then + for i = #list, limit + 1, -1 do + list[i] = nil + end + end + return list +end + +------------------------------------------------------------------------ +-- Listen fuer die Oberflaeche +------------------------------------------------------------------------ + +-- Alle IDs einer Art (sortiert) +function Seed.IDs(kind) + if not Seed.Load() then + return {} + end + local c = caches.lists[kind] + if c then + return c + end + local src + if kind == "quests" then src = S.q + elseif kind == "units" then src = S.u + elseif kind == "objects" then src = S.o + elseif kind == "items" then src = S.ie end + local list = {} + for id in pairs(src or {}) do + list[#list + 1] = id + end + if kind == "items" then + for id in pairs(S.i) do + if not S.ie[id] then list[#list + 1] = id end + end + end + table.sort(list) + caches.lists[kind] = list + return list +end + +-- Kleingeschriebene Namen (deutsch und englisch) zur Suche +function Seed.Haystack(kind, id) + local c = caches.hay[kind] + if not c then + c = {} + caches.hay[kind] = c + end + local h = c[id] + if h then + return h + end + local de, en + if kind == "quests" then de, en = S.qd[id], S.qe[id] + elseif kind == "units" then de, en = S.ud[id], S.ue[id] + elseif kind == "objects" then de, en = S.od[id], S.oe[id] + else de, en = S.id[id], S.ie[id] end + h = ((de or "") .. "\n" .. (en or "")):lower() + c[id] = h + return h +end + +function Seed.Count() + if not Seed.Load() then + return 0, 0, 0, 0 + end + local function n(t) + local c = 0 + for _ in pairs(t) do c = c + 1 end + return c + end + return n(S.q), n(S.u), n(S.o), n(S.i) +end diff --git a/CodexShare.lua b/CodexShare.lua new file mode 100644 index 0000000..0c2dbf5 --- /dev/null +++ b/CodexShare.lua @@ -0,0 +1,2314 @@ +-- Freewood-OctoWoW-Codex: Datenschicht fuers Teilen +-- +-- * Eigene Datensaetze (Kreatur, Objekt, Quest, Erfahrungstabelle) werden bereinigt und mit einem +-- Aenderungsstand (upd) versehen. Der Stand liegt in db.sync.stamps und wird aus dem Inhalt +-- berechnet, so muss die Erfassung selbst nichts davon wissen. +-- * Empfangene Datensaetze liegen getrennt je Absender in db.remote und werden nie in die +-- eigenen Daten geschrieben. Ein neuer Stand desselben Absenders ersetzt den alten (idempotent). +-- * Share.View() liefert die Sicht fuer die Anzeige: eigene und fremde Daten zusammengerechnet. +-- Ohne fremde Daten ist das einfach Codex.db. +-- +-- Schluessel eines Datensatzes: "u123" Kreatur, "o45" Objekt, "q678" Quest, "x0" Erfahrung, "t0" Kampfdauer. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Share = {} +Codex.Share = Share +NS.Share = Share + +local GRID = Codex.GRID or 200 +local MAX_SENDERS = 12 +local MAX_RECORDS = 2500 -- je Absender +local MAX_PATH_RECORDS = 60000 -- Wegenetz-Bloecke je Absender (nur der Stand wird gemerkt) +local MAX_CELLS_SHARED = 120 +local MAX_DROPS = 80 +local MAX_NAMES = 6000 +local SELF_IMPORT = "~import" + +local KIND_OF = { u = "units", o = "objects", q = "quests", i = "ied" } +local LETTER_OF = { unit = "u", object = "o", quest = "q", item = "i" } +local CLASSES = { elite = true, rare = true, rareelite = true, worldboss = true } +local XP_CLASSES = { normal = true, elite = true, rare = true, rareelite = true, worldboss = true } + +------------------------------------------------------------------------ +-- Pruefer (alles, was von aussen kommt, geht hier durch) +------------------------------------------------------------------------ + +local function Text(v, max) + if type(v) ~= "string" then + return nil + end + v = v:gsub("|[cC]%x%x%x%x%x%x%x%x", ""):gsub("|[rR]", ""):gsub("|T.-|t", ""):gsub("|H.-|h", "") + v = v:gsub("|", ""):gsub("%c", " ") + v = v:gsub("^%s+", ""):gsub("%s+$", "") + if v == "" then + return nil + end + if #v > max then + v = v:sub(1, max) + end + return v +end + +local function Int(v, lo, hi) + if type(v) ~= "number" or v ~= v or v == math.huge or v == -math.huge then + return nil + end + v = math.floor(v) + if v < lo or v > hi then + return nil + end + return v +end + +local function Frac(v) + if type(v) ~= "number" or v ~= v or v < 0 or v > 1 then + return nil + end + return math.floor(v * 10000 + 0.5) / 10000 +end + +local function Sample(s) + if type(s) ~= "table" then + return nil + end + local n, sum = Int(s.n, 1, 1000000), Int(s.sum, 0, 10000000000) + local lo, hi = Int(s.min, 0, 10000000), Int(s.max, 0, 10000000) + if not (n and sum and lo and hi) or lo > hi then + return nil + end + if sum < lo * n - 1 or sum > hi * n + 1 then + return nil + end + return { n = n, sum = sum, min = lo, max = hi } +end + +-- Tabelle { [schluessel] = zahl } mit Obergrenze; die groessten Werte bleiben +local function TopCounts(src, keyFn, valLo, valHi, limit) + local list = {} + local seen = 0 + for k, v in pairs(src) do + seen = seen + 1 + if seen > 20000 then + break + end + local key, val = keyFn(k), Int(v, valLo, valHi) + if key and val then + list[#list + 1] = { key, val } + end + end + table.sort(list, function(a, b) + if a[2] ~= b[2] then + return a[2] > b[2] + end + return a[1] < b[1] + end) + local out, count = {}, 0 + for _, e in ipairs(list) do + if count >= limit then + break + end + out[e[1]] = e[2] + count = count + 1 + end + return out, count +end + +local function DropTable(src, k) + if type(src) ~= "table" or not k then + return nil + end + local list = {} + local seen = 0 + for id, d in pairs(src) do + seen = seen + 1 + if seen > 2000 then + break + end + local iid = Int(id, 1, 10000000) + if iid and type(d) == "table" then + local n = Int(d.n, 1, 1000000) + if n and n <= k then + local q = Int(d.q, n, 100000000) or n + local qk = d.qi and Int(d.qk, n, k) or nil + list[#list + 1] = { id = iid, n = n, q = q, qi = d.qi and 1 or nil, qk = qk } + end + end + end + table.sort(list, function(a, b) + if a.n ~= b.n then + return a.n > b.n + end + return a.id < b.id + end) + local out, count = {}, 0 + for _, e in ipairs(list) do + if count >= MAX_DROPS then + break + end + out[e.id] = { n = e.n, q = e.q, qi = e.qi, qk = e.qk } + count = count + 1 + end + if count == 0 then + return nil + end + return out +end + +local function Ref(r) + if type(r) ~= "table" then + return nil + end + local k = r.k + if k ~= "npc" and k ~= "object" and k ~= "other" then + return nil + end + return { + k = k, id = Int(r.id, 0, 100000000), n = Text(r.n, 60), m = Int(r.m, 0, 100000), + x = Frac(r.x), y = Frac(r.y), + } +end + +local function RewardList(list) + if type(list) ~= "table" then + return nil + end + local out = {} + for i = 1, math.min(#list, 12) do + local e = list[i] + local id = type(e) == "table" and Int(e.id, 1, 10000000) + if id then + out[#out + 1] = { id = id, c = Int(e.c, 1, 1000) or 1 } + end + end + return #out > 0 and out or nil +end + +local function SrcKey(k) + if type(k) == "string" and k:match("^[uo]%d+$") and #k <= 12 then + return k + end + return nil +end + +local function ObjectiveList(list) + if type(list) ~= "table" then + return nil + end + local out = {} + for i = 1, math.min(#list, 12) do + local o = list[i] + if type(o) == "table" then + local ty = Text(o.ty, 16) + local e = { + ty = ty and ty:lower():gsub("[^a-z]", "") or nil, + name = Text(o.name, 80), txt = Text(o.txt, 100), + req = Int(o.req, 0, 100000), item = Int(o.item, 1, 10000000), + } + if e.ty == "" then e.ty = nil end + if type(o.ids) == "table" then + e.ids = TopCounts(o.ids, function(k) return Int(k, 1, 100000000) end, 1, 1000000, 12) + if next(e.ids) == nil then e.ids = nil end + end + out[i] = e + else + out[i] = {} + end + end + return #out > 0 and out or nil +end + +local function QuestItems(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for item, sources in pairs(src) do + local iid = Int(item, 1, 10000000) + if iid and type(sources) == "table" and count < 12 then + local s = TopCounts(sources, SrcKey, 1, 1000000, 12) + if next(s) ~= nil then + out[iid] = s + count = count + 1 + end + end + end + return count > 0 and out or nil +end + +local function XPTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for pl, byMl in pairs(src) do + local p = Int(pl, 1, 80) + if p and type(byMl) == "table" then + for ml, byCls in pairs(byMl) do + local m = Int(ml, 1, 130) + if m and type(byCls) == "table" then + for cls, s in pairs(byCls) do + if XP_CLASSES[cls] and count < 400 then + local sm = Sample(s) + if sm then + out[p] = out[p] or {} + out[p][m] = out[p][m] or {} + out[p][m][cls] = sm + count = count + 1 + end + end + end + end + end + end + end + return count > 0 and out or nil +end + +-- Kampfdauer: ttk[Klasse][Spielerstufe][Gegnerstufe - Spielerstufe] = { n, sum, min, max } (Zehntelsekunden) +local function TTKTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for cls, byPl in pairs(src) do + if type(cls) == "string" and cls:match("^%u+$") and #cls <= 16 and type(byPl) == "table" then + for pl, byDelta in pairs(byPl) do + local p = Int(pl, 1, 80) + if p and type(byDelta) == "table" then + for delta, s in pairs(byDelta) do + local d = Int(delta, -5, 5) + local sm = d and count < 1200 and Sample(s) + if sm then + out[cls] = out[cls] or {} + out[cls][p] = out[cls][p] or {} + out[cls][p][d] = sm + count = count + 1 + end + end + end + end + end + end + return count > 0 and out or nil +end + +-- Kampfdauer einer Kreatur: tt[Klasse][Spielerstufe] = { n, sum, min, max } (Zehntelsekunden) +local function UnitTTK(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for cls, byPl in pairs(src) do + if type(cls) == "string" and cls:match("^%u+$") and #cls <= 16 and type(byPl) == "table" then + for pl, s in pairs(byPl) do + local p = Int(pl, 1, 80) + local sm = p and count < 60 and Sample(s) + if sm then + out[cls] = out[cls] or {} + out[cls][p] = sm + count = count + 1 + end + end + end + end + return count > 0 and out or nil +end + +-- Rollen: nur bekannte Namen, Zaehler begrenzt +local ROLE_KEYS = { + vendor = true, repair = true, flight = true, innkeeper = true, stablemaster = true, spirithealer = true, + auctioneer = true, battlemaster = true, banker = true, classtrainer = true, proftrainer = true, mailbox = true, +} +local MAX_WARES_SHARED = 90 +local MAX_TRAIN_SHARED = 100 +local MAX_DEST_SHARED = 40 +local MAX_PRICES_SHARED = 4 + +local function RoleTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for role, n in pairs(src) do + local v = type(role) == "string" and ROLE_KEYS[role] and Int(n, 1, 100000) + if v then + out[role] = v + count = count + 1 + end + end + return count > 0 and out or nil +end + +-- { [preis in Kupfer] = Anzahl }, hoechstens MAX_PRICES_SHARED (haeufigste) +local function PriceMap(src) + if type(src) ~= "table" then + return nil + end + local out, count = TopCounts(src, function(k) + return Int(k, 0, 2000000000) + end, 1, 100000, MAX_PRICES_SHARED) + return count > 0 and out or nil +end + +local function SortedKeys(src, keyFn) + local list = {} + local seen = 0 + for k, v in pairs(src) do + seen = seen + 1 + if seen > 5000 then + break + end + local key = keyFn(k) + if key ~= nil and type(v) == "table" then + list[#list + 1] = { key, v } + end + end + table.sort(list, function(a, b) + return a[1] < b[1] + end) + return list +end + +local function WareTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for _, e in ipairs(SortedKeys(src, function(k) + return Int(k, 1, 10000000) + end)) do + if count >= MAX_WARES_SHARED then + break + end + local pr = PriceMap(e[2].pr) + if pr then + out[e[1]] = { pr = pr, st = Int(e[2].st, 2, 1000), lim = e[2].lim and 1 or nil, ec = e[2].ec and 1 or nil } + count = count + 1 + end + end + return count > 0 and out or nil +end + +local function TrainTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for _, e in ipairs(SortedKeys(src, function(k) + return Text(k, 100) + end)) do + if count >= MAX_TRAIN_SHARED then + break + end + local r = e[2] + local nm = Text(r.nm, 60) + local pr = PriceMap(r.pr) + if nm and pr then + out[e[1]] = { nm = nm, sp = Int(r.sp, 1, 10000000), rk = Text(r.rk, 30), lv = Int(r.lv, 1, 130), sn = Text(r.sn, 40), sr = Int(r.sr, 1, 1000), pr = pr } + count = count + 1 + end + end + return count > 0 and out or nil +end + +local function FlightTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for _, e in ipairs(SortedKeys(src, function(k) + return Text(k, 60) + end)) do + if count >= MAX_DEST_SHARED then + break + end + local pr = PriceMap(e[2]) + if pr then + out[e[1]] = pr + count = count + 1 + end + end + return count > 0 and out or nil +end + +local function PerLevel(src, depth) + -- xm[spielerstufe][gegnerstufe] bzw. xp[spielerstufe] + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for pl, v in pairs(src) do + local p = Int(pl, 1, 80) + if p and type(v) == "table" then + if depth == 1 then + local sm = Sample(v) + if sm and count < 40 then + out[p] = sm + count = count + 1 + end + else + for ml, s in pairs(v) do + local m = Int(ml, 1, 130) + local sm = m and Sample(s) + if sm and count < 40 then + out[p] = out[p] or {} + out[p][m] = sm + count = count + 1 + end + end + end + end + end + return count > 0 and out or nil +end + +-- DEV-Funktionen: Namen und "fehlerhaft"-Markierung (bad: 1 = fehlerhaft, 0 = Markierung aufgehoben) +local function BadFlag(v) + if v == true or v == 1 then return 1 end + if v == false or v == 0 then return 0 end + return nil +end + +local function CanEditNames() + return Codex.Trust ~= nil and Codex.Trust.CanEdit() +end + +local BUILTIN = "~forever" -- die mitgelieferten Startdaten (Freewood-OctoWoW-ForeverData): wie ein Berechtigter, aber ganz unten + +local function IsDevSender(name) + if name == BUILTIN then return true end + return Codex.Trust ~= nil and Codex.Trust.IsDev(name) +end + +-- Eigenschaften (Zonen, Dungeon, Klasse, Stufen) von Berechtigten; auf der Leitung ist dg 1 / 0 +local function WireBool(v) + if v == true or v == 1 then return true end + if v == false or v == 0 then return false end + return nil +end + +local function SanitizeEd(src, kind) + if type(src) ~= "table" then return nil end + local probe = { z = src.z, dg = WireBool(src.dg), cl = src.cl, l1 = src.l1, l2 = src.l2, pre = src.pre, + key = src.key, nx = src.nx, bf = src.bf, af = src.af, ql = src.ql, tx = src.tx, si = src.si, qk = src.qk, qd = src.qd, qb = src.qb, qr = src.qr } + local ed = Codex.CleanEd(probe, kind) + if not ed then return nil end + if ed.dg ~= nil then ed.dg = ed.dg and 1 or 0 end + return ed +end + +-- Aus dem Datensatz eines anderen Spielers in die eigene Form (dg als Wahrheitswert) +local function EdFromWire(ed) + if type(ed) ~= "table" then return nil end + local out = Codex.CopyEd(ed) + out.dg = WireBool(ed.dg) + return out +end + +-- Texte je Teilfeld fuer das Protokoll +local function EdFields(ed) + local f = {} + if type(ed) ~= "table" then return f end + if ed.z then f.ez = table.concat(ed.z, ",") end + local dg = WireBool(ed.dg) + if dg ~= nil then f.edg = dg and "1" or "0" end + f.ecl = ed.cl + if ed.l1 then f.el = ed.l1 .. "-" .. (ed.l2 or ed.l1) end + if ed.pre then f.epre = table.concat(ed.pre, ",") end + if ed.key then f.ekey = tostring(ed.key) end + if ed.nx then f.enx = table.concat(ed.nx, ",") end + if ed.bf then f.ebf = table.concat(ed.bf, ",") end + if ed.af then f.eaf = table.concat(ed.af, ",") end + if ed.ql then f.eql = tostring(ed.ql) end + if ed.tx then f.etx = ed.tx end + if ed.si then f.esi = tostring(ed.si) end + if ed.qk then f.eqk = table.concat(ed.qk, ",") end + if ed.qd then f.eqd = table.concat(ed.qd, ",") end + if ed.qb then f.eqb = table.concat(ed.qb, ",") end + if ed.qr then f.eqr = table.concat(ed.qr, ",") end + return f +end +Share.EdFields = EdFields +-- alle Teilfelder der Eigenschaften (fuer Vorschau und Protokoll) +Share.ED_FIELDS = { "ez", "edg", "ecl", "el", "epre", "ekey", "enx", "ebf", "eaf", "eql", "etx", "esi", "eqk", "eqd", "eqb", "eqr" } + +-- Instanzen, in denen eine Kreatur / ein Objekt gesehen wurde: { [instanceID] = Anzahl }, hoechstens 3 +local function SanitizeIz(src) + if type(src) ~= "table" then return nil end + local out, count = {}, 0 + for id, n in pairs(src) do + local iid, cnt = Int(id, 1, 100000), Int(n, 1, 1000000) + if iid and cnt and count < 3 then + out[iid] = cnt + count = count + 1 + end + end + return count > 0 and out or nil +end + +local function SanitizeEntity(rec, isObject) + local out = {} + out.n = Text(rec.n, 60) + out.cde, out.cen = Text(rec.cde, 60), Text(rec.cen, 60) + out.bad = BadFlag(rec.bad) + out.ed = SanitizeEd(rec.ed, isObject and "objects" or "units") + out.iz = SanitizeIz(rec.iz) + if not isObject then + out.l1, out.l2 = Int(rec.l1, 1, 130), Int(rec.l2, 1, 130) + if out.l1 and out.l2 and out.l1 > out.l2 then + out.l1, out.l2 = out.l2, out.l1 + end + out.c = CLASSES[rec.c] and rec.c or nil + out.f = Text(rec.f, 40) + end + out.k = Int(rec.k, 0, 1000000) + local k = out.k or 0 + if k > 0 then + out.coinN = Int(rec.coinN, 0, k) + out.coin = out.coinN and out.coinN > 0 and Int(rec.coin, 0, 1000000000000) or nil + if not out.coin then + out.coinN = nil + end + end + if type(rec.cells) == "table" then + local cells, count = TopCounts(rec.cells, function(key) + return Int(key, 0, 1000000000) + end, 1, 1000000, MAX_CELLS_SHARED) + if count > 0 then + out.cells = cells + end + end + out.d = DropTable(rec.d, k) + if type(rec.alt) == "table" then + local ak = Int(rec.alt.k, 1, 1000000) + if ak then + out.alt = { k = ak, d = DropTable(rec.alt.d, ak) } + end + end + out.ro = RoleTable(rec.ro) + if not isObject then + out.xm = PerLevel(rec.xm, 2) + out.tt = UnitTTK(rec.tt) + out.ti = Text(rec.ti, 40) + out.wa = WareTable(rec.wa) + out.tr = TrainTable(rec.tr) + if type(rec.trc) == "string" and Codex.CLASS_TOKENS and Codex.CLASS_TOKENS[rec.trc] then out.trc = rec.trc end + out.fl = FlightTable(rec.fl) + out.fn = Text(rec.fn, 60) + out.ct = (type(rec.ct) == "string" and Codex.CT_CODES and Codex.CT_CODES[rec.ct]) and rec.ct or nil + out.ne = Text(rec.ne, 60) + end + if type(rec.nm) == "table" then + local nm, count = {}, 0 + local function Wanted(id) + return (out.d and out.d[id]) or (out.alt and out.alt.d and out.alt.d[id]) + end + for id, name in pairs(rec.nm) do + local iid = Int(id, 1, 10000000) + local t = iid and Wanted(iid) and Text(name, 80) + if t and count < MAX_DROPS then + nm[iid] = t + count = count + 1 + end + end + if count > 0 then + out.nm = nm + end + end + if not (out.n or (out.k and out.k > 0) or out.cells or out.iz or out.cde or out.cen or out.bad or out.ed or out.tt or out.xm or out.ro or out.wa or out.tr or out.fl) then + return nil + end + return out +end + +-- Items: nur die Eigenschaften von Berechtigten +local function SanitizeItem(rec, id) + local out = { ed = SanitizeEd(rec.ed, (id and id >= Codex.ZONE_BASE) and "zones" or "items") } + if not out.ed then return nil end + return out +end + +local QTEXT_CAP = { d = 900, o = 400, p = 400, r = 600 } +local function QuestTexts(tx) + if type(tx) ~= "table" then return nil end + local out, any = {}, false + for f, cap in pairs(QTEXT_CAP) do + local v = Text(tx[f], cap) + if v and #v >= 3 then + out[f] = v + any = true + end + end + return any and out or nil +end + +local function SanitizeQuest(rec) + local out = {} + out.t = Text(rec.t, 100) + if not out.t then + return nil + end + out.cde, out.cen = Text(rec.cde, 100), Text(rec.cen, 100) + out.bad = BadFlag(rec.bad) + out.ed = SanitizeEd(rec.ed, "quests") + out.lvl, out.pl = Int(rec.lvl, 0, 130), Int(rec.pl, 0, 130) + out.zone = Int(rec.zone, 0, 100000) + out.give, out.turn = Ref(rec.give), Ref(rec.turn) + out.si = Int(rec.si, 1, 100000000) + -- Kette und Voraussetzungen: Vorquests (jede genuegt), Mindeststufe, Volks- und Klassenmaske + if type(rec.pre) == "table" then + local pre = {} + for i = 1, math.min(#rec.pre, 8) do + local v = Int(rec.pre[i], 1, 100000000) + if v then pre[#pre + 1] = v end + end + if #pre > 0 then out.pre = pre end + end + out.mn, out.rc, out.cl = Int(rec.mn, 1, 130), Int(rec.rc, 1, 4294967295), Int(rec.cl, 1, 4294967295) + out.ev = Int(rec.ev, 1, 255) -- Event-Nummer (Weltevent): nur Event-Quests plant der Levelplaner nur auf Wunsch + -- Beobachtungen zur Verfuegbarkeit: pi (Kandidat -> Anzahl), po (Beobachtungen), mi (Stufenbereich) + if type(rec.pi) == "table" then + local pi, count = {}, 0 + for k, v in pairs(rec.pi) do + local id, n = Int(k, 1, 100000000), Int(v, 1, 100000) + if id and n and count < 8 then pi[id] = n count = count + 1 end + end + local po = Int(rec.po, 1, 100000) + if count > 0 and po then out.pi, out.po = pi, po end + end + if type(rec.mi) == "table" then + local lo, hi, n = Int(rec.mi.lo, 0, 130), Int(rec.mi.hi, 1, 130), Int(rec.mi.n, 1, 100000) + if lo and hi and n and hi > lo then out.mi = { lo = lo, hi = hi, n = n } end + end + out.xp, out.money = Int(rec.xp, 0, 10000000), Int(rec.money, 0, 1000000000) + if type(rec.rep) == "table" then + local rep, count = {}, 0 + for name, v in pairs(rec.rep) do + local nm, val = Text(name, 60), Int(v, -100000, 100000) + if nm and val and count < 8 then + rep[nm] = val + count = count + 1 + end + end + if count > 0 then + out.rep = rep + end + end + out.fixed, out.choice = RewardList(rec.fixed), RewardList(rec.choice) + out.tm, out.tr, out.ta = Sample(rec.tm), Sample(rec.tr), Sample(rec.ta) + out.obj = ObjectiveList(rec.obj) + out.qi = QuestItems(rec.qi) + out.tx = QuestTexts(rec.tx) + return out +end + +local function SanitizeXPRecord(rec) + local xp = XPTable(rec.xp) + if not xp then + return nil + end + return { xp = xp } +end + +local function SanitizeTTKRecord(rec) + local tk = TTKTable(rec.tk) + if not tk then + return nil + end + return { tk = tk } +end + +-- Verkehrsverbindung (Schiff, Zeppelin, ...): von Berechtigten erfasst +local function SanitizeTransportRecord(rec) + local T = Codex.Transport + local kindOk = false + for _, k in ipairs(T and T.KINDS or {}) do + if rec.k == k then kindOk = true end + end + if not kindOk then return nil end + local function Spot(p) + if type(p) ~= "table" then return nil end + local m, x, y = Int(p.m, -1000000, 1000000), Frac(p.x), Frac(p.y) + if not (m and x and y) then return nil end + return { m = m, x = x, y = y } + end + local a, b = Spot(rec.a), Spot(rec.b) + local t = Int(rec.t, 1, 7200) + if not (a and b and t) then return nil end + local out = { k = rec.k, t = t, a = a, b = b, two = rec.two ~= false and true or false } + if rec.f == "A" or rec.f == "H" then out.f = rec.f end + out.n = Text(rec.n, 40) + if rec.del == true then out.del = true end + return out +end + +-- Ortspunkt (Instanz-Eingang, Berg-Aufstieg, ...): von Berechtigten erfasst +local function SanitizeSpotRecord(rec) + local S = Codex.Spots + local kindOk = false + for _, k in ipairs(S and S.KINDS or {}) do + if rec.k == k then kindOk = true end + end + if not kindOk or type(rec.p) ~= "table" then return nil end + local m, x, y = Int(rec.p.m, -1000000, 1000000), Frac(rec.p.x), Frac(rec.p.y) + if not (m and x and y) then return nil end + local out = { k = rec.k, p = { m = m, x = x, y = y }, q = {} } + if type(rec.q) == "table" then + local seen = {} + for _, v in ipairs(rec.q) do + local id = Int(v, 1, 1000000) + if id and not seen[id] and #out.q < (S and S.MAX_QUESTS or 20) then + seen[id] = true + out.q[#out.q + 1] = id + end + end + table.sort(out.q) + end + out.am = Int(rec.am, -1000000, 1000000) + if rec.f == "A" or rec.f == "H" then out.f = rec.f end + out.n = Text(rec.n, 40) + if rec.del == true then out.del = true end + if not out.am and #out.q == 0 and not out.del then return nil end + return out +end + +-- Flugroute: Dauer, Preis, Strecke (Weltpunkte) und die Namen der beiden Flugpunkte +local function SanitizeTaxiNode(n) + if type(n) ~= "table" then return nil end + local out = { n = Text(n.n, 60), a = Int(n.a, -1000000, 1000000), b = Int(n.b, -1000000, 1000000) } + local m, x, y = Int(n.m, 1, 1000000), Frac(n.x), Frac(n.y) + if m and x and y then out.m, out.x, out.y = m, x, y end + return out +end + +local function SanitizeTaxiRecord(rec) + local t, c = Int(rec.t, 5, 7200), Int(rec.c, 0, 100000000) + if not t or not c or type(rec.p) ~= "table" then return nil end + local cnt = #rec.p + if cnt < 4 or cnt > 80 or cnt % 2 ~= 0 then return nil end + local p = {} + for i = 1, cnt do + local v = Int(rec.p[i], -1000000, 1000000) + if not v then return nil end + p[i] = v + end + local A, B = SanitizeTaxiNode(rec.A), SanitizeTaxiNode(rec.B) + if not A or not B then return nil end + return { t = t, c = c, n = Int(rec.n or 1, 1, 100000) or 1, p = p, A = A, B = B } +end + +-- Wegenetz-Block: Text "Zelle.Maske,..." wird geprueft und in Normalform gebracht +local function SanitizePathRecord(rec) + local P = Codex.Path + local cells = P and P.ParseChunk(rec.c) + if not cells then + return nil + end + return { c = P.ChunkText(cells) } +end + +local function SanitizeLinkRecord(rec) + local P = Codex.Path + local list = P and P.ParseLinks(rec.l) + if not list then + return nil + end + return { l = list } +end + +-- key: "u123"; rec: unbekannte Eingabe. Rueckgabe: bereinigter Datensatz { y, id, upd, ... } oder nil +function Share.Sanitize(key, rec, upd) + if type(key) ~= "string" or type(rec) ~= "table" then + return nil + end + local letter, num = key:match("^([uoqixtpzghf])(%d+)$") + local id = num and Int(tonumber(num), 0, (letter == "f") and 100000000000000 or (letter == "p") and 65536000000 or 100000000) + if not letter or not id then + return nil + end + upd = Int(upd, 1, 4000000000) + if not upd then + return nil + end + local out + if letter == "u" then + out = SanitizeEntity(rec, false) + elseif letter == "o" then + out = SanitizeEntity(rec, true) + elseif letter == "q" then + out = SanitizeQuest(rec) + elseif letter == "i" then + out = SanitizeItem(rec, id) + elseif letter == "t" then + out = SanitizeTTKRecord(rec) + elseif letter == "p" then + out = SanitizePathRecord(rec) + elseif letter == "z" then + out = SanitizeLinkRecord(rec) + elseif letter == "g" then + out = SanitizeTransportRecord(rec) + elseif letter == "h" then + out = SanitizeSpotRecord(rec) + elseif letter == "f" then + out = SanitizeTaxiRecord(rec) + else + out = SanitizeXPRecord(rec) + end + if not out then + return nil + end + out.y, out.id, out.upd = letter, id, upd + return out +end + +------------------------------------------------------------------------ +-- Eigene Datensaetze und ihr Stand +------------------------------------------------------------------------ + +local function Store() + local db = Codex.db + db.sync = db.sync or {} + db.sync.stamps = db.sync.stamps or {} + db.sync.sent = db.sync.sent or 0 + db.sync.received = db.sync.received or 0 + return db.sync +end +Share.Store = Store + +local function Sum(t) + local s = 0 + for _, v in pairs(t) do + s = s + (type(v) == "table" and (v.n or 0) or v) + end + return s +end + +local function BucketSig(b) + local s = (b.k or 0) * 17 + for _, d in pairs(b.d or {}) do + s = s + (d.n or 0) * 13 + 1 + end + return s +end + +local function StrSig(str) + if type(str) ~= "string" then + return 0 + end + local h = #str + for i = 1, math.min(#str, 100) do + h = (h * 31 + str:byte(i)) % 1000003 + end + return h +end + +local function EdSig(ed) + if type(ed) ~= "table" then return 0 end + local h = 0 + for k, v in pairs(EdFields(ed)) do + h = h + StrSig(k) * 3 + StrSig(tostring(v)) * 59 + end + return h +end + +local function Signature(letter, e) + local s + if letter == "i" then + return (1 + EdSig(e.ed)) % 2147483647 + elseif letter == "q" then + s = (e.seen or 0) * 3 + (e.done or 0) * 5 + #(e.t or "") * 7 + (e.xp or 0) + (e.money or 0) + s = s + #(e.fixed or {}) * 11 + #(e.choice or {}) * 13 + (e.partial and 1 or 0) + s = s + (e.tm and e.tm.n or 0) * 23 + (e.tr and e.tr.n or 0) * 29 + (e.ta and e.ta.n or 0) * 31 + for i, o in ipairs(e.obj or {}) do + s = s + #(o.name or "") * (i + 1) + (o.item or 0) + Sum(o.ids or {}) * 3 + 1 + end + for item, srcs in pairs(e.qi or {}) do + s = s + item % 997 + Sum(srcs) + end + if e.give then s = s + (e.give.id or 0) end + if e.turn then s = s + (e.turn.id or 0) end + s = s + (e.si or 0) % 1019 + for _, p in ipairs(e.pre or {}) do s = s + p % 997 * 3 + 1 end + s = s + (e.mn or 0) * 5 + (e.rc or 0) % 1009 + (e.cl or 0) % 1013 + if type(e.tx) == "table" then + for _, v in pairs(e.tx) do s = s + #tostring(v) * 3 + 1 end + end + for p2, n2 in pairs(e.pi or {}) do s = s + p2 % 991 * n2 end + s = s + (e.po or 0) * 3 + (e.mi and (e.mi.lo * 7 + e.mi.hi * 11 + (e.mi.n or 0)) or 0) + if CanEditNames() then s = s + StrSig(e.cde) * 17 + StrSig(e.cen) * 19 + (BadFlag(e.bad) or 7) * 53 + EdSig(e.ed) end + else + s = BucketSig(e) + Sum(e.coinN and { e.coinN } or {}) * 5 + #(e.n or "") * 7 + (e.l1 or 0) + (e.l2 or 0) * 11 + (e.ct and #e.ct * 37 or 0) + (e.ne and #e.ne * 41 or 0) + if CanEditNames() then s = s + StrSig(e.cde) * 17 + StrSig(e.cen) * 19 + (BadFlag(e.bad) or 7) * 53 + EdSig(e.ed) end + s = s + Sum(e.iz or {}) * 61 + for _ in pairs(e.cells or {}) do + s = s + 1 + end + s = s + Sum(e.cells or {}) + if e.alt then + s = s + BucketSig(e.alt) * 3 + end + for _, byMl in pairs(e.xm or {}) do + s = s + Sum(byMl) * 19 + end + for _, byPl in pairs(e.tt or {}) do + for _, st in pairs(byPl) do + s = s + (st.n or 0) * 37 + (st.sum or 0) % 101 + end + end + s = s + Sum(e.ro or {}) * 41 + StrSig(e.ti) * 43 + StrSig(e.fn) * 47 + StrSig(e.trc) * 53 + for id, w in pairs(e.wa or {}) do + s = s + id % 991 + Sum(w.pr or {}) * 3 + (w.st or 0) + for price in pairs(w.pr or {}) do + s = s + price % 997 + end + end + for key, r in pairs(e.tr or {}) do + s = s + #key + (r.sp or 0) % 997 + (r.lv or 0) * 5 + (r.sr or 0) + Sum(r.pr or {}) * 3 + for price in pairs(r.pr or {}) do + s = s + price % 997 + end + end + for dest, pr in pairs(e.fl or {}) do + s = s + #dest * 7 + Sum(pr) * 3 + for price in pairs(pr) do + s = s + price % 997 + end + end + end + return s % 2147483647 +end + +local function IsShareable(letter, e) + if type(e) ~= "table" then + return false + end + if letter == "q" then + return type(e.t) == "string" and not e.loc -- eigene Quests des Creators bleiben bei dir + end + if letter == "i" then + return CanEditNames() and e.ed ~= nil + end + -- auch ohne Namen (zum Beispiel in Instanzen gelootete Kreaturen); Namen kommen von anderen Spielern + -- auch mit nur einem von Hand eingetragenen Namen + -- (auch Kreaturen, bei denen nur Kampfdauer oder Erfahrung gemessen wurde) + local named = CanEditNames() and (e.cde ~= nil or e.cen ~= nil or e.bad ~= nil or e.ed ~= nil) + return ((e.k or 0) > 0) or e.cells ~= nil or e.iz ~= nil or named or e.tt ~= nil or e.xm ~= nil + or e.ro ~= nil or e.wa ~= nil or e.tr ~= nil or e.fl ~= nil +end + +Share.IsShareable = IsShareable + +local function XPSignature() + local s, any = 0, false + for _, byMl in pairs(Codex.db.xp or {}) do + for _, byCls in pairs(byMl) do + for _, sm in pairs(byCls) do + s = s + (sm.n or 0) * 7 + 1 + any = true + end + end + end + return any and s % 2147483647 or nil +end + +local function TTKSignature() + local s, any = 0, false + for _, byPl in pairs(Codex.db.ttk or {}) do + for _, byDelta in pairs(byPl) do + for _, sm in pairs(byDelta) do + s = s + (sm.n or 0) * 7 + 1 + any = true + end + end + end + return any and s % 2147483647 or nil +end + +local scanCache, scanAt = nil, 0 +local firstScanDone = false + +local function Touch(stamps, key, sig, now) + local s = stamps[key] + if not s then + stamps[key] = { sig = sig, upd = now } + return true + elseif s.sig ~= sig then + s.sig = sig + s.upd = math.max(now, (s.upd or 0) + 1) + return true + end + return false +end + +-- Alle eigenen Datensaetze durchgehen. Rueckgabe: Liste { key, upd }, Anzahl, Pruefwert, geaenderte Schluessel +function Share.Scan(force) + local now = time() + if scanCache and not force and now - scanAt < 30 then + return scanCache.list, scanCache.count, scanCache.hash, {} + end + local store = Store() + local stamps = store.stamps + local list, changed, hash = {}, {}, 0 + for letter, field in pairs(KIND_OF) do + for id, e in pairs(Codex.db[field]) do + if type(id) == "number" and IsShareable(letter, e) then + local key = letter .. id + if Touch(stamps, key, Signature(letter, e), now) then + changed[#changed + 1] = key + end + local upd = stamps[key].upd + list[#list + 1] = { key = key, upd = upd } + hash = (hash + (id % 100003) * 31 + upd % 1000003 + (letter:byte() * 7)) % 2147483647 + end + end + end + local xs = XPSignature() + if xs then + if Touch(stamps, "x0", xs, now) then + changed[#changed + 1] = "x0" + end + list[#list + 1] = { key = "x0", upd = stamps.x0.upd } + hash = (hash + stamps.x0.upd % 1000003 + 5) % 2147483647 + end + local ts = TTKSignature() + if ts then + if Touch(stamps, "t0", ts, now) then + changed[#changed + 1] = "t0" + end + list[#list + 1] = { key = "t0", upd = stamps.t0.upd } + hash = (hash + stamps.t0.upd % 1000003 + 11) % 2147483647 + end + for id, rec in pairs(Codex.db.transport or {}) do + if type(id) == "number" and type(rec) == "table" and rec.upd then + list[#list + 1] = { key = "g" .. id, upd = rec.upd } + hash = (hash + id % 100003 * 17 + rec.upd % 1000003 + 103) % 2147483647 + end + end + for id, rec in pairs(Codex.db.spots or {}) do + if type(id) == "number" and type(rec) == "table" and rec.upd then + list[#list + 1] = { key = "h" .. id, upd = rec.upd } + hash = (hash + id % 100003 * 19 + rec.upd % 1000003 + 107) % 2147483647 + end + end + if Codex.Taxi and Codex.Taxi.SyncList then + for _, e in ipairs(Codex.Taxi.SyncList()) do + list[#list + 1] = e + hash = (hash + (tonumber(e.key:sub(2)) or 0) % 100003 * 23 + e.upd % 1000003 + 109) % 2147483647 + end + end + if Codex.Path and Codex.Path.SyncList then + for _, e in ipairs(Codex.Path.SyncList()) do + list[#list + 1] = e + hash = (hash + (tonumber(e.key:sub(2)) or 0) % 100003 * 13 + e.upd % 1000003 + e.key:byte() * 3) % 2147483647 + end + end + scanCache = { list = list, count = #list, hash = hash } + scanAt = now + if not firstScanDone then + firstScanDone = true + changed = {} + end + return list, #list, hash, changed +end + +function Share.Summary() + local _, count, hash = Share.Scan(false) + return count, hash +end + +function Share.InvalidateScan() + scanCache = nil +end + +-- Stand eines einzelnen eigenen Datensatzes neu berechnen. Rueckgabe: key, upd, geaendert +function Share.StampOne(kind, id) + local letter = LETTER_OF[kind] + if not letter or type(id) ~= "number" then + return nil + end + local e = Codex.db[KIND_OF[letter]][id] + if not IsShareable(letter, e) then + return nil + end + local key = letter .. id + local stamps = Store().stamps + local changed = Touch(stamps, key, Signature(letter, e), time()) + scanCache = nil + return key, stamps[key].upd, changed +end + +function Share.OwnUpd(key) + local s = Store().stamps[key] + return s and s.upd or nil +end + +-- Verzeichnis: eigene Datensaetze mit Stand > since, aelteste zuerst +function Share.Directory(since, limit) + local list = Share.Scan(false) + local out = {} + for _, e in ipairs(list) do + if e.upd > (since or 0) then + out[#out + 1] = e + end + end + table.sort(out, function(a, b) + if a.upd ~= b.upd then + return a.upd < b.upd + end + return a.key < b.key + end) + while #out > (limit or 400) do + out[#out] = nil + end + return out +end + +-- Eigener Datensatz zum Senden (bereinigt). Rueckgabe: record, upd +function Share.OwnRecord(key) + local letter, num = tostring(key):match("^([uoqixtpzghf])(%d+)$") + if not letter then + return nil + end + local id = tonumber(num) + if letter == "g" then + local rec = Codex.db.transport and Codex.db.transport[id] + if type(rec) ~= "table" or not rec.upd then return nil end + local out = Share.Sanitize(key, rec, rec.upd) + return out, rec.upd + end + if letter == "h" then + local rec = Codex.db.spots and Codex.db.spots[id] + if type(rec) ~= "table" or not rec.upd then return nil end + local out = Share.Sanitize(key, rec, rec.upd) + return out, rec.upd + end + if letter == "f" then + local T = Codex.Taxi + local raw, upd = T and T.RouteRecord(id) + if not raw then return nil end + return Share.Sanitize(key, raw, upd), upd + end + if letter == "p" or letter == "z" then + local P = Codex.Path + if not P then return nil end + local list = Share.Scan(false) + local upd + for _, e in ipairs(list) do + if e.key == key then upd = e.upd break end + end + if not upd then return nil end + local raw = letter == "p" and P.ChunkRecord(id) or P.LinkRecord() + if not raw then return nil end + local rec = Share.Sanitize(key, raw, upd) + return rec, upd + end + local upd = Share.OwnUpd(key) + if not upd then + Share.Scan(true) + upd = Share.OwnUpd(key) + end + if not upd then + return nil + end + local raw + if letter == "x" then + raw = { xp = Codex.db.xp } + elseif letter == "t" then + raw = { tk = Codex.db.ttk } + else + local e = Codex.db[KIND_OF[letter]][id] + if not IsShareable(letter, e) then + return nil + end + raw = e + if letter == "u" or letter == "o" then + local nm = {} + for _, bucket in ipairs({ e, e.alt or {} }) do + for itemID in pairs(bucket.d or {}) do + local name = Codex.db.names[itemID] + if name then + nm[itemID] = name + end + end + end + raw = setmetatable({ nm = nm }, { __index = e }) + end + end + local rec = Share.Sanitize(key, raw, upd) + -- Namen und Markierungen verschicken nur Berechtigte (mit Modul Freewood-OctoWoW-Dev) + if rec and (letter == "u" or letter == "o" or letter == "q" or letter == "i") and not CanEditNames() then + rec.cde, rec.cen, rec.bad, rec.ed = nil, nil, nil, nil + end + return rec, upd +end + +-- Zu grosse Datensaetze verkleinern (Waren, Lehrstoff, Flugziele halbieren, kleinste Schluessel bleiben) +function Share.Shrink(rec) + if type(rec) ~= "table" then + return nil + end + local out, cut = {}, false + for k, v in pairs(rec) do + out[k] = v + end + if rec.tx then -- zu gross: zuerst die Quest-Texte weglassen + out.tx = nil + cut = true + end + for _, field in ipairs({ "wa", "tr", "fl" }) do + local src = rec[field] + if type(src) == "table" then + local keys = {} + for key in pairs(src) do + keys[#keys + 1] = key + end + table.sort(keys, function(a, b) + if type(a) == type(b) then return a < b end + return type(a) == "number" + end) + if #keys > 8 then + local kept = {} + for i = 1, math.floor(#keys / 2) do + kept[keys[i]] = src[keys[i]] + end + out[field] = kept + cut = true + end + end + end + if not cut then + return nil + end + return out +end + +------------------------------------------------------------------------ +-- Fremde Daten +------------------------------------------------------------------------ + +local view, index, allDirty, dirtyIds, xpRecs, xpDirty = nil, nil, true, {}, {}, false +local lastXPBuild = -1000 -- Zeit des letzten Neubaus der XP- und Kampfzeit-Sicht (hoechstens alle 20 s) +local ttkRecs = {} +local nameCount + +local function Remote() + local db = Codex.db + db.remote = db.remote or {} + return db.remote +end + +function Share.RemoteStats() + local senders, records = 0, 0 + for _, s in pairs(Remote()) do + senders = senders + 1 + records = records + (s.n or 0) + end + return senders, records +end + +-- Mitgelieferte Startdaten (nur im Speicher, nie gespeichert und nie gesendet): Schluessel -> Datensatz +local builtin = { rec = {}, n = 0 } + +function Share.HasBuiltin() + return builtin.n > 0 +end + +function Share.BuiltinCount() + return builtin.n +end + +-- Woher stammt ein Datensatz? "own" = eigene Beobachtung, "remote" = von anderen Spielern, "start" = nur aus den Startdaten +function Share.Origin(letter, id) + local field = KIND_OF[letter] + if not field or type(id) ~= "number" then return nil end + local own = Codex.db and Codex.db[field] and Codex.db[field][id] + if type(own) == "table" then return "own" end + local key = letter .. id + for _, s in pairs(Remote()) do + if s.rec and s.rec[key] then return "remote" end + end + if builtin.rec[key] then return "start" end + return nil +end + +function Share.HasRemote() + return next(Remote()) ~= nil or builtin.n > 0 +end + +function Share.Sender(name, create) + local R = Remote() + local s = R[name] + if not s and create then + local count, oldest, oldestT = 0, nil, math.huge + for key, v in pairs(R) do + count = count + 1 + if key ~= SELF_IMPORT and (v.seen or 0) < oldestT then + oldest, oldestT = key, v.seen or 0 + end + end + if count >= MAX_SENDERS and oldest then + R[oldest] = nil + allDirty = true + end + s = { seen = time(), rec = {}, n = 0, cursor = 0 } + R[name] = s + end + return s +end + +local function NoteItemNames(nm) + if type(nm) ~= "table" then + return + end + local names = Codex.db.names + if not nameCount then + nameCount = 0 + for _ in pairs(names) do + nameCount = nameCount + 1 + end + end + for id, name in pairs(nm) do + if names[id] == nil and nameCount < MAX_NAMES then + names[id] = name + nameCount = nameCount + 1 + end + end +end + +-- Bereinigten Datensatz eines Absenders speichern. Rueckgabe: true, wenn neu oder neuer +function Share.StoreRecord(name, rec) + if type(name) ~= "string" or type(rec) ~= "table" or not rec.y then + return false + end + local s = Share.Sender(name, true) + local key = rec.y .. rec.id + local old = s.rec[key] + if old and old.upd >= rec.upd then + return false + end + if rec.y == "g" then + -- Verkehrsverbindungen nur von Berechtigten (oder aus dem eigenen Import) + if name ~= SELF_IMPORT and not IsDevSender(name) then return false end + if not old then + if (s.np or 0) >= MAX_PATH_RECORDS then return false end + s.np = (s.np or 0) + 1 + end + s.rec[key] = rec + s.seen = time() + if Codex.Transport then Codex.Transport.rev = (Codex.Transport.rev or 0) + 1 end + FW:Fire("CODEX_TRANSPORT_CHANGED", rec.id) + return true + end + if rec.y == "h" then + -- Ortspunkte nur von Berechtigten (oder aus dem eigenen Import) + if name ~= SELF_IMPORT and not IsDevSender(name) then return false end + if not old then + if (s.np or 0) >= MAX_PATH_RECORDS then return false end + s.np = (s.np or 0) + 1 + end + s.rec[key] = rec + s.seen = time() + if Codex.Spots then Codex.Spots.Bump(rec.id) end + return true + end + if rec.y == "f" then + -- Flugrouten: in die gemeinsamen Flugdaten einmischen, vom Absender bleibt nur der Stand + local T = Codex.Taxi + if not T or not T.StoreRemote(rec) then return false end + if not old then + if (s.np or 0) >= MAX_PATH_RECORDS then return false end + s.np = (s.np or 0) + 1 + end + s.rec[key] = { y = rec.y, id = rec.id, upd = rec.upd } + s.seen = time() + return true + end + if rec.y == "p" or rec.y == "z" then + -- Wegenetz: sofort in die gemeinsamen Daten einmischen, vom Absender bleibt nur der Stand + local P = Codex.Path + if not P then return false end + if not old then + if (s.np or 0) >= MAX_PATH_RECORDS then return false end + s.np = (s.np or 0) + 1 + end + if rec.y == "p" then + local cells = P.ParseChunk(rec.c) + if not cells then return false end + P.MergeChunk(rec.id, cells) + else + P.MergeLinks(rec.l) + end + s.rec[key] = { y = rec.y, id = rec.id, upd = rec.upd } + s.seen = time() + return true + end + if not old then + if (s.n or 0) >= MAX_RECORDS then + return false + end + s.n = (s.n or 0) + 1 + end + -- Aenderungen von Berechtigten melden (das Modul Freewood-OctoWoW-Dev schreibt sie ins Protokoll) + if Codex.DevLog and (rec.cde or rec.cen or rec.bad ~= nil or rec.ed or (old and (old.cde or old.cen or old.bad ~= nil or old.ed))) and IsDevSender(name) then + local o = old or {} + for _, f in ipairs({ "cde", "cen", "bad" }) do + if rec[f] ~= o[f] then + pcall(Codex.DevLog, { who = name, kind = rec.y, id = rec.id, field = f, old = o[f], new = rec[f], remote = true }) + end + end + local nf, of = EdFields(rec.ed), EdFields(o.ed) + for _, f in ipairs(Share.ED_FIELDS) do + if nf[f] ~= of[f] then + pcall(Codex.DevLog, { who = name, kind = rec.y, id = rec.id, field = f, old = of[f], new = nf[f], remote = true }) + end + end + end + NoteItemNames(rec.nm) + rec.nm = nil + s.rec[key] = rec + s.seen = time() + if rec.y == "x" or rec.y == "t" then + allDirty = true + else + dirtyIds[key] = true + end + return true +end + +-- Startdaten laden: list = Liste roher Datensaetze { y, id, upd, ... }; jeder geht durch Sanitize +-- (Wegenetz fliesst in die gemeinsamen Daten, alles andere liegt als unterste Schicht im Speicher). +-- Rueckgabe: Anzahl angenommener Datensaetze +-- Startdaten in Scheiben laden (Begin, Add je Scheibe, Done), damit das Spiel beim Anmelden nicht stehen bleibt. +-- SetBuiltin(list) macht alles auf einmal. +function Share.BuiltinBegin() + builtin = { rec = {}, n = 0 } +end + +function Share.BuiltinAdd(list, from, to) + local accepted = 0 + local P = Codex.Path + for i = from or 1, to or #list do + local raw = list[i] + local key = type(raw) == "table" and type(raw.y) == "string" and type(raw.id) == "number" + and (raw.y .. math.floor(raw.id)) or nil + local rec = key and Share.Sanitize(key, raw, raw.upd) + if rec then + if rec.y == "p" then + local cells = P and P.ParseChunk(rec.c) + if cells then P.MergeChunk(rec.id, cells) accepted = accepted + 1 end + elseif rec.y == "z" then + if P and P.MergeLinks(rec.l) then accepted = accepted + 1 end + else + builtin.rec[key] = rec + builtin.n = builtin.n + 1 + accepted = accepted + 1 + if rec.y == "u" or rec.y == "o" then NoteItemNames(rec.nm) end + end + end + end + return accepted +end + +function Share.BuiltinDone() + view, allDirty = nil, true + if Codex.Transport then Codex.Transport.rev = (Codex.Transport.rev or 0) + 1 end + if Codex.Spots then Codex.Spots.Bump() end +end + +function Share.SetBuiltin(list) + Share.BuiltinBegin() + local accepted = Share.BuiltinAdd(list or {}, 1, #(list or {})) + Share.BuiltinDone() + return accepted +end + +-- Empfangene Datensaetze einer Art (nur von aktuell berechtigten Absendern, fuer "g") +function Share.RemoteRecords(letter) + local out = {} + for _, rec in pairs(builtin.rec) do + if rec.y == letter and (letter == "g" or letter == "h") then out[#out + 1] = rec end + end + for name, s in pairs(Remote()) do + if (letter ~= "g" and letter ~= "h") or name == SELF_IMPORT or IsDevSender(name) then + for _, rec in pairs(s.rec) do + if rec.y == letter then out[#out + 1] = rec end + end + end + end + return out +end + +function Share.HaveRemote(name, key) + local s = Remote()[name] + local r = s and s.rec[key] + return r and r.upd or nil +end + +function Share.ClearRemote() + local R = Remote() + local count = 0 + for name, s in pairs(R) do + count = count + (s.n or 0) + R[name] = nil + end + allDirty = true + view = nil + if Codex.Path and Codex.Path.ClearRemote then Codex.Path.ClearRemote() end + if Codex.Taxi and Codex.Taxi.ClearRemote then Codex.Taxi.ClearRemote() end + if Codex.Transport then Codex.Transport.rev = (Codex.Transport.rev or 0) + 1 end + if Codex.Spots then Codex.Spots.Bump() end + return count +end + +------------------------------------------------------------------------ +-- Zusammenfuehren +------------------------------------------------------------------------ + +local function AddSample(dst, s) + dst.n = (dst.n or 0) + s.n + dst.sum = (dst.sum or 0) + s.sum + dst.min = dst.min and math.min(dst.min, s.min) or s.min + dst.max = dst.max and math.max(dst.max, s.max) or s.max +end + +local function CopySample(s) + return { n = s.n, sum = s.sum, min = s.min, max = s.max } +end + +local function MergeLevels(dst, src, depth) + for pl, v in pairs(src or {}) do + if depth == 1 then + dst[pl] = dst[pl] or {} + AddSample(dst[pl], v) + else + dst[pl] = dst[pl] or {} + for ml, s in pairs(v) do + dst[pl][ml] = dst[pl][ml] or {} + AddSample(dst[pl][ml], s) + end + end + end +end + +local function MergeBucket(dst, src) + dst.k = (dst.k or 0) + (src.k or 0) + if src.d then + dst.d = dst.d or {} + for id, d in pairs(src.d) do + local t = dst.d[id] + if not t then + t = { n = 0, q = 0 } + dst.d[id] = t + end + t.n = t.n + (d.n or 0) + t.q = t.q + (d.q or 0) + if d.qi then + t.qi = 1 + t.qk = (t.qk or 0) + (d.qk or 0) + end + end + end +end + +local function MergeUnitTTK(dst, src) + for cls, byPl in pairs(src or {}) do + dst[cls] = dst[cls] or {} + for pl, st in pairs(byPl) do + dst[cls][pl] = dst[cls][pl] or {} + AddSample(dst[cls][pl], st) + end + end +end + +local function MergePrices(dst, src) + for price, n in pairs(src or {}) do + dst[price] = (dst[price] or 0) + n + end +end + +local function MergeRoles(dst, src) + for role, n in pairs(src or {}) do + dst[role] = (dst[role] or 0) + n + end +end + +local function MergeWares(dst, src) + for id, w in pairs(src or {}) do + local d = dst[id] + if not d then + d = { pr = {} } + dst[id] = d + end + MergePrices(d.pr, w.pr) + d.st = d.st or w.st + d.lim = d.lim or w.lim + d.ec = d.ec or w.ec + end +end + +local function MergeTrain(dst, src) + for key, r in pairs(src or {}) do + local d = dst[key] + if not d then + d = { pr = {} } + dst[key] = d + end + MergePrices(d.pr, r.pr) + d.nm = d.nm or r.nm + d.sp = d.sp or r.sp + d.rk = d.rk or r.rk + d.lv = d.lv or r.lv + d.sn = d.sn or r.sn + d.sr = d.sr or r.sr + end +end + +local function MergeFlights(dst, src) + for dest, pr in pairs(src or {}) do + dst[dest] = dst[dest] or {} + MergePrices(dst[dest], pr) + end +end + +-- Namen und Markierung kommen nur von berechtigten Absendern; bei mehreren gilt der neueste Stand, +-- eigene Werte gehen vor. Die Absender-Datensaetze bleiben unveraendert (ein gesperrter Absender wird so wieder wirksam). +local function MergeEditorFields(m, e) + if m._init == nil then + m._init = true + local editor = CanEditNames() + m._ownName = editor and (m.cde ~= nil or m.cen ~= nil) + if not editor then m.bad = nil end + m._ownBad = editor and m.bad ~= nil + if not editor then m.ed = nil end + m._ownEd = editor and m.ed ~= nil + end + local r = e.rec + if not IsDevSender(e.s) then return end + if r.ed and not m._ownEd and (not m._edUpd or r.upd > m._edUpd) then + m.ed, m._edUpd = EdFromWire(r.ed), r.upd + end + if (r.cde or r.cen) and not m._ownName and (not m._nameUpd or r.upd > m._nameUpd) then + m.cde, m.cen, m._nameUpd = r.cde, r.cen, r.upd + end + if r.bad ~= nil and not m._ownBad and (not m._badUpd or r.upd > m._badUpd) then + m.bad, m._badUpd = r.bad == 1, r.upd + end +end + +local function MergeEntity(own, list) + local m = {} + local remoteK = 0 + if own then + m.n, m.l1, m.l2, m.c, m.f = own.n, own.l1, own.l2, own.c, own.f + m.ct, m.ne = own.ct, own.ne + m.cde, m.cen = own.cde, own.cen + m.bad = own.bad + m.ed = own.ed + if own.iz then + m.iz = {} + for id, n in pairs(own.iz) do m.iz[id] = n end + end + m.coin, m.coinN = own.coin, own.coinN + m.cells = {} + for key, n in pairs(own.cells or {}) do + m.cells[key] = n + end + MergeBucket(m, own) + if own.alt then + m.alt = {} + MergeBucket(m.alt, own.alt) + end + m.xm, m.xp = {}, {} + MergeLevels(m.xm, own.xm, 2) + MergeLevels(m.xp, own.xp, 1) + m.tt = {} + MergeUnitTTK(m.tt, own.tt) + m.ro, m.wa, m.tr, m.fl = {}, {}, {}, {} + MergeRoles(m.ro, own.ro) + MergeWares(m.wa, own.wa) + MergeTrain(m.tr, own.tr) + MergeFlights(m.fl, own.fl) + m.ti, m.fn, m.trc = own.ti, own.fn, own.trc + else + m.cells, m.xm, m.xp, m.tt = {}, {}, {}, {} + m.ro, m.wa, m.tr, m.fl = {}, {}, {}, {} + end + for _, e in ipairs(list) do + local r = e.rec + m.n = m.n or r.n + MergeEditorFields(m, e) + m.f = m.f or r.f + m.ct = m.ct or r.ct + m.ne = m.ne or r.ne + m.c = m.c or r.c + if r.iz then + m.iz = m.iz or {} + local cnt = 0 + for _ in pairs(m.iz) do cnt = cnt + 1 end + for id, n in pairs(r.iz) do + if m.iz[id] or cnt < 3 then + if not m.iz[id] then cnt = cnt + 1 end + m.iz[id] = (m.iz[id] or 0) + n + end + end + end + if r.l1 then m.l1 = m.l1 and math.min(m.l1, r.l1) or r.l1 end + if r.l2 then m.l2 = m.l2 and math.max(m.l2, r.l2) or r.l2 end + if r.coinN and r.coinN > 0 then + m.coin = (m.coin or 0) + (r.coin or 0) + m.coinN = (m.coinN or 0) + r.coinN + end + for key, n in pairs(r.cells or {}) do + m.cells[key] = (m.cells[key] or 0) + n + end + MergeBucket(m, r) + remoteK = remoteK + (r.k or 0) + if r.alt then + m.alt = m.alt or {} + MergeBucket(m.alt, r.alt) + end + MergeLevels(m.xm, r.xm, 2) + MergeUnitTTK(m.tt, r.tt) + MergeRoles(m.ro, r.ro) + MergeWares(m.wa, r.wa) + MergeTrain(m.tr, r.tr) + MergeFlights(m.fl, r.fl) + m.ti = m.ti or r.ti + m.trc = m.trc or r.trc + m.fn = m.fn or r.fn + end + if next(m.ro) == nil then m.ro = nil end + if next(m.wa) == nil then m.wa = nil end + if next(m.tr) == nil then m.tr = nil end + if Codex.LearnPlanReset then Codex.LearnPlanReset() end + if next(m.fl) == nil then m.fl = nil end + if next(m.tt) == nil then m.tt = nil end + if next(m.cells) == nil then m.cells = nil end + if next(m.xm) == nil then m.xm = nil end + if next(m.xp) == nil then m.xp = nil end + m.rk = remoteK > 0 and remoteK or nil + m.remote = #list + m.k = m.k or nil + return m +end + +local function PlusSample(a, b) + if not b then return a end + if not a then return CopySample(b) end + local out = CopySample(a) + AddSample(out, b) + return out +end + +local function MergeQuest(own, list) + local m = {} + if own then + for k, v in pairs(own) do + m[k] = v + end + end + local objCopied = false + for _, e in ipairs(list) do + local r = e.rec + MergeEditorFields(m, e) + for _, f in ipairs({ "t", "lvl", "pl", "zone", "give", "turn", "si", "xp", "money", "rep", "fixed", "choice", "mn", "rc", "cl", "ev" }) do + if m[f] == nil then + m[f] = r[f] + end + end + if r.tx then + -- Quest-Texte: fehlende Teile aus fremden Daten (neue Tabelle, die eigene bleibt unveraendert) + local tx = {} + for k, v in pairs(m.tx or {}) do tx[k] = v end + for k, v in pairs(r.tx) do + if not tx[k] then tx[k] = v end + end + m.tx = tx + end + if r.pi and r.po then + -- Beobachtungen zusammenzaehlen (neue Tabelle, die eigene bleibt unveraendert) + local pi = {} + for k, v in pairs(m.pi or {}) do pi[k] = v end + local count = 0 + for _ in pairs(pi) do count = count + 1 end + for k, v in pairs(r.pi) do + if pi[k] or count < 8 then + if not pi[k] then count = count + 1 end + pi[k] = (pi[k] or 0) + v + end + end + m.pi, m.po = pi, (m.po or 0) + r.po + end + if r.mi then + local mi = m.mi + if mi then + local lo, hi = math.max(mi.lo, r.mi.lo), math.min(mi.hi, r.mi.hi) + if hi > lo then m.mi = { lo = lo, hi = hi, n = (mi.n or 1) + (r.mi.n or 1) } end + else + m.mi = r.mi + end + end + if r.pre then + -- Vorquests vereinen + local have, merged = {}, {} + for _, p in ipairs(m.pre or {}) do have[p] = true merged[#merged + 1] = p end + for _, p in ipairs(r.pre) do + if not have[p] and #merged < 8 then have[p] = true merged[#merged + 1] = p end + end + m.pre = merged + end + for _, f in ipairs({ "tm", "tr", "ta" }) do + if r[f] then m[f] = PlusSample(m[f], r[f]) end + end + if r.obj then + if not m.obj then + m.obj = r.obj + objCopied = false + else + for i, ro in ipairs(r.obj) do + local mo = m.obj[i] + if mo and (not mo.ty or not ro.ty or mo.ty == ro.ty) and + ((not mo.name and ro.name) or (not mo.item and ro.item) or (not mo.ids and ro.ids)) then + if not objCopied then + local copy = {} + for j, o in ipairs(m.obj) do + local c = {} + for k, v in pairs(o) do c[k] = v end + copy[j] = c + end + m.obj = copy + objCopied = true + end + mo = m.obj[i] + mo.ty = mo.ty or ro.ty + mo.name = mo.name or ro.name + mo.txt = mo.txt or ro.txt + mo.req = mo.req or ro.req + mo.item = mo.item or ro.item + mo.ids = mo.ids or ro.ids + end + end + end + end + if r.qi then + local copy = {} + for item, srcs in pairs(m.qi or {}) do + copy[item] = {} + for k, v in pairs(srcs) do copy[item][k] = v end + end + for item, srcs in pairs(r.qi) do + copy[item] = copy[item] or {} + for k, v in pairs(srcs) do + copy[item][k] = math.max(copy[item][k] or 0, v) + end + end + m.qi = copy + end + end + if not own then + m.seen, m.done, m.first, m.last = 0, 0, nil, nil + m.rem = true + end + return m +end + +local function BuildXP() + local db = Codex.db + local xp = {} + if #xpRecs > 0 then + for pl, byMl in pairs(db.xp or {}) do + for ml, byCls in pairs(byMl) do + for cls, s in pairs(byCls) do + xp[pl] = xp[pl] or {} + xp[pl][ml] = xp[pl][ml] or {} + xp[pl][ml][cls] = CopySample(s) + end + end + end + for _, rec in ipairs(xpRecs) do + for pl, byMl in pairs(rec.xp or {}) do + for ml, byCls in pairs(byMl) do + for cls, s in pairs(byCls) do + xp[pl] = xp[pl] or {} + xp[pl][ml] = xp[pl][ml] or {} + xp[pl][ml][cls] = xp[pl][ml][cls] or {} + AddSample(xp[pl][ml][cls], s) + end + end + end + end + view.xp = xp + else + view.xp = db.xp + end + xpDirty = false +end + +local function BuildTTK() + local db = Codex.db + if #ttkRecs == 0 then + view.ttk = db.ttk + return + end + local tk = {} + local function Add(src) + for cls, byPl in pairs(src or {}) do + for pl, byDelta in pairs(byPl) do + for d, sm in pairs(byDelta) do + tk[cls] = tk[cls] or {} + tk[cls][pl] = tk[cls][pl] or {} + tk[cls][pl][d] = PlusSample(tk[cls][pl][d], sm) + end + end + end + end + Add(db.ttk) + for _, rec in ipairs(ttkRecs) do + Add(rec.tk) + end + view.ttk = tk +end + +local function Rebuild() + local db = Codex.db + index = { u = {}, o = {}, q = {}, i = {} } + xpRecs = {} + ttkRecs = {} + local names = {} + for name in pairs(Remote()) do + names[#names + 1] = name + end + table.sort(names) + for _, name in ipairs(names) do + local s = Remote()[name] + for _, rec in pairs(s.rec) do + if rec.y == "x" then + xpRecs[#xpRecs + 1] = rec + elseif rec.y == "t" then + ttkRecs[#ttkRecs + 1] = rec + elseif rec.y == "p" or rec.y == "z" or rec.y == "g" or rec.y == "h" or rec.y == "f" then + -- Wegenetz liegt schon in den gemeinsamen Daten (Codex.Path), Verkehrsverbindungen liest Codex.Transport + else + local bucket = index[rec.y] + bucket[rec.id] = bucket[rec.id] or {} + local list = bucket[rec.id] + list[#list + 1] = { s = name, rec = rec } + end + end + end + -- Startdaten ganz unten (nach allen Absendern) + local bkeys = {} + for key in pairs(builtin.rec) do bkeys[#bkeys + 1] = key end + table.sort(bkeys) + for _, key in ipairs(bkeys) do + local rec = builtin.rec[key] + if rec.y == "x" then + xpRecs[#xpRecs + 1] = rec + elseif rec.y == "t" then + ttkRecs[#ttkRecs + 1] = rec + elseif index[rec.y] then + local bucket = index[rec.y] + bucket[rec.id] = bucket[rec.id] or {} + table.insert(bucket[rec.id], { s = BUILTIN, rec = rec }) + end + end + view = { quests = {}, units = {}, objects = {}, ied = {}, xpb = db.xpb, names = db.names } + for letter, field in pairs(KIND_OF) do + local out = view[field] + for id, e in pairs(db[field]) do + out[id] = e.ed and Codex.ApplyEd(e, field) or e + end + for id, list in pairs(index[letter]) do + local merged + if letter == "q" then + merged = MergeQuest(db[field][id], list) + else + merged = MergeEntity(db[field][id], list) + end + if merged.bad == true and not CanEditNames() then merged = nil end + if merged then merged = Codex.ApplyEd(merged, field, true) end + out[id] = merged + end + end + BuildXP() + BuildTTK() + allDirty = false + dirtyIds = {} +end + +local function Refresh(key) + local letter, num = key:match("^([uoqi])(%d+)$") + if not letter then + return + end + local id = tonumber(num) + local field = KIND_OF[letter] + local list = index[letter][id] + -- neue Fremd-Eintraege in den Index aufnehmen + list = nil + for name, s in pairs(Remote()) do + local rec = s.rec[key] + if rec then + list = list or {} + list[#list + 1] = { s = name, rec = rec } + end + end + if list then + table.sort(list, function(a, b) return a.s < b.s end) + end + if builtin.rec[key] then + list = list or {} + list[#list + 1] = { s = BUILTIN, rec = builtin.rec[key] } + end + index[letter][id] = list + local own = Codex.db[field][id] + if list then + local merged = letter == "q" and MergeQuest(own, list) or MergeEntity(own, list) + if merged.bad == true and not CanEditNames() then merged = nil end + if merged then merged = Codex.ApplyEd(merged, field, true) end + view[field][id] = merged + else + view[field][id] = own and own.ed and Codex.ApplyEd(own, field) or own + end +end + +-- Sicht fuer die Anzeige: Felder quests, units, objects, xp, xpb, names +-- Berechtigungen haben sich geaendert: Sicht neu aufbauen +function Share.TrustChanged() + allDirty = true +end + +-- Eigene Eigenschaften (ed) muessen auch ohne fremde Daten in die Sicht +local hasEd +function Share.HasEd() + if hasEd == nil then + hasEd = false + -- aeltere Form der Item-Eigenschaften (ohne Huelle ed) umwandeln + for id, e in pairs(Codex.db.ied or {}) do + if e.ed == nil and (e.z or e.dg ~= nil) then Codex.db.ied[id] = { ed = e } end + end + for _, field in ipairs({ "units", "objects", "quests", "ied" }) do + for _, e in pairs(Codex.db[field] or {}) do + if e.ed then hasEd = true break end + end + if hasEd then break end + end + end + return hasEd +end + +function Share.NoteEd(present) + if present then hasEd = true else hasEd = nil end -- nil: beim naechsten Mal neu pruefen + view, allDirty = nil, true +end + +function Share.View() + if not Share.HasRemote() and not Share.HasEd() then + view, allDirty = nil, true + return Codex.db + end + if not view or allDirty then + Rebuild() + elseif xpDirty then + -- Erfahrungs- und Kampfzeit-Sicht nicht nach jedem Kill neu bauen (kann mit fremden Daten mehrere 100 ms dauern) + local t = _G.GetTime and _G.GetTime() or 0 + if t - lastXPBuild >= 20 then + lastXPBuild = t + BuildXP() + BuildTTK() + end + end + if next(dirtyIds) then + local ids = dirtyIds + dirtyIds = {} + for key in pairs(ids) do + Refresh(key) + end + end + return view +end + +-- Eigene Aenderung: nur noetig, wenn es zu diesem Datensatz fremde Daten gibt oder er in der Sicht fehlt +function Share.OnChanged(kind, id) + if not view then + return + end + if kind == "xp" or kind == "ttk" then + if #xpRecs > 0 or #ttkRecs > 0 then + xpDirty = true + end + return + end + local letter = LETTER_OF[kind] + if letter and type(id) == "number" then + local field = KIND_OF[letter] + if view[field][id] == nil or index[letter][id] then + dirtyIds[letter .. id] = true + end + end +end + +FW:On("CODEX_CHANGED", function(_, kind, id) + Share.OnChanged(kind, id) +end, Share) + +-- Gemessene Kampfdauer in Sekunden und Anzahl fuer (Klasse, Spielerstufe, Stufenabstand) +function Share.TTKMeasured(cls, pl, delta) + local tk = Share.View().ttk + local s = tk and tk[cls] and tk[cls][pl] and tk[cls][pl][delta] + if not s or not s.n or s.n < 1 then + return nil + end + return s.sum / s.n / 10, s.n +end + +-- Kampfdauer aller Klassen bei (Spielerstufe, Stufenabstand): Liste { cls, secs, n } nach Dauer sortiert +function Share.TTKClasses(pl, delta) + local list = {} + local tk = Share.View().ttk + for cls, byPl in pairs(tk or {}) do + local s = byPl[pl] and byPl[pl][delta] + if s and s.n and s.n > 0 then + list[#list + 1] = { cls = cls, secs = s.sum / s.n / 10, n = s.n } + end + end + table.sort(list, function(a, b) + if a.secs ~= b.secs then return a.secs < b.secs end + return a.cls < b.cls + end) + return list +end + +-- Gemessene Erfahrung fuer (Spielerstufe, Gegnerstufe, Klasse): Mittelwert und Anzahl, +-- nur wenn die Messwerte zusammenpassen +function Share.XPMeasured(pl, ml, cls) + local xp = Share.View().xp + local s = xp and xp[pl] and xp[pl][ml] and xp[pl][ml][cls] + if not s or not s.n or s.n < 1 then + return nil + end + local avg = s.sum / s.n + if (s.max - s.min) > math.max(2, avg * 0.1) then + return nil + end + return math.floor(avg + 0.5), s.n +end + +------------------------------------------------------------------------ +-- Export und Import als Code +------------------------------------------------------------------------ + +-- Empfangene Datensaetze aller Absender (je Schluessel der neueste), fuer berechtigte Bearbeiter +local function ReceivedRecords(limit, skip) + local best = {} + for _, s in pairs(Remote()) do + for key, rec in pairs(s.rec) do + if rec.y ~= "p" and rec.y ~= "z" and rec.y ~= "f" and not skip[key] and (not best[key] or rec.upd > best[key].upd) then + best[key] = rec + end + end + end + local list = {} + for _, rec in pairs(best) do list[#list + 1] = rec end + table.sort(list, function(a, b) + if a.upd ~= b.upd then return a.upd > b.upd end + return (a.y .. a.id) < (b.y .. b.id) + end) + local out = {} + for i = 1, math.min(#list, limit) do out[i] = list[i] end + return out +end + +local function ExportList(limit, withReceived) + local list = Share.Scan(true) + table.sort(list, function(a, b) + if a.upd ~= b.upd then + return a.upd > b.upd + end + return a.key < b.key + end) + local out, have = {}, {} + for i = 1, math.min(#list, limit) do + local rec = Share.OwnRecord(list[i].key) + if rec then + out[#out + 1] = rec + have[list[i].key] = true + end + end + if withReceived and #out < limit then + for _, rec in ipairs(ReceivedRecords(limit - #out, have)) do + out[#out + 1] = rec + end + end + return out +end + +-- withReceived: auch empfangene Daten anderer Spieler (nur fuer Berechtigte mit Modul Freewood-OctoWoW-Dev) +function Share.ExportData(withReceived) + if withReceived and not (Codex.Trust and Codex.Trust.Can("export")) then + return {} + end + local limit = 1500 + local data + repeat + data = { list = ExportList(limit, withReceived) } + local ok = true + if FW.Export and FW.Export.Encode then + local text = FW.Export.Encode("codex", data) + ok = text ~= nil + end + if ok or limit <= 25 or #data.list == 0 then + break + end + limit = math.floor(limit / 2) + until false + if #data.list == 0 then + return {} + end + return data +end + +function Share.ValidateImport(data) + return type(data) == "table" and type(data.list) == "table" and #data.list > 0 and #data.list <= 5000 +end + +function Share.ImportData(data) + local L = FW.L + local added, skipped = 0, 0 + for _, raw in ipairs(data.list or {}) do + local key = type(raw) == "table" and type(raw.y) == "string" and type(raw.id) == "number" + and (raw.y .. math.floor(raw.id)) or nil + local rec = key and Share.Sanitize(key, raw, raw.upd) + if rec and Share.StoreRecord(SELF_IMPORT, rec) then + added = added + 1 + else + skipped = skipped + 1 + end + end + FW:Fire("CODEX_CHANGED", "import") + return string.format(L.CODEX_IMPORT_STATS, added, skipped) +end + +if FW.Export and FW.Export.RegisterHandler then + FW.Export.RegisterHandler("codex", { + label = function() + return FW.L.CODEX_EXPORT_LABEL + end, + export = function() + return Share.ExportData() + end, + validate = function(data) + return Share.ValidateImport(data) + end, + import = function(data) + return Share.ImportData(data) + end, + }) +end diff --git a/CodexShareUI.lua b/CodexShareUI.lua new file mode 100644 index 0000000..972e421 --- /dev/null +++ b/CodexShareUI.lua @@ -0,0 +1,94 @@ +-- Freewood-OctoWoW-Codex: Abschnitt "Datenbank teilen" in den Einstellungen + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +local Sync = NS and NS.Sync +local Share = NS and NS.Share +if not FW or not Codex or not Sync or not Share or not FW.UI or not FW.UI.RegisterSettingsSection then + return +end + +local UI = FW.UI + +local function Build(parent) + local L = FW.L + local f, stack = UI.CreateSection(parent, L.CODEX_SHARE_HEADER) + + local note = UI.CreateLabel(f, L.CODEX_SHARE_NOTE, { size = 11, color = "textDim", justifyV = "TOP" }) + stack:Add(note, { fit = true, gap = 8 }) + + local box = UI.CreateCheckbox(f, L.CODEX_SHARE_SWITCH, function() + return Sync:IsEnabled() + end, function(on) + Sync:SetEnabled(on) + end) + stack:Add(box, { fit = true, gap = 10 }) + UI.AttachTooltip(box, function() return L.CODEX_SHARE_SWITCH end, function() return L.CODEX_SHARE_SWITCH_TIP end) + + local callButton = UI.CreateButton(f, L.CODEX_SHARE_NOW, 200, 26, function() + if Sync:IsEnabled() then + Sync:JoinGlobal() + Share.Scan(true) + Sync:SendHello() + end + end, "default") + stack:Add(callButton, { height = 26, gap = 12 }) + + local clearButton + local armed = false + clearButton = UI.CreateButton(f, L.CODEX_SHARE_CLEAR, 240, 26, function() + if not armed then + armed = true + clearButton:SetLabel(L.CODEX_SHARE_CLEAR_SURE) + C_Timer.After(5, function() + armed = false + clearButton:SetLabel(L.CODEX_SHARE_CLEAR) + end) + return + end + armed = false + clearButton:SetLabel(L.CODEX_SHARE_CLEAR) + local n = Share.ClearRemote() + FW:Print(string.format(L.CODEX_SHARE_CLEARED, n)) + FW:Fire("CODEX_CHANGED", "import") + FW:Fire("CODEX_SYNC_CHANGED") + end, "default") + stack:Add(clearButton, { height = 26, gap = 8 }) + + local status = UI.CreateLabel(f, "", { size = 11, color = "textDim", justifyV = "TOP" }) + stack:Add(status, { fit = true, gap = 10 }) + + function f.Refresh() + box:Refresh() + local st = Sync:Stats() + local text = string.format(L.CODEX_SHARE_STATS, st.sent, st.received) + text = text .. "\n" .. string.format(L.CODEX_SHARE_SOURCES, st.senders, st.records) + text = text .. "\n" .. string.format(L.CODEX_SHARE_HEARD, st.heard or 0) + if Sync:IsEnabled() then + local key = "CODEX_SHARE_GLOBAL_" .. string.upper(st.global or "off") + text = text .. "\n" .. (L[key] or L.CODEX_SHARE_GLOBAL_OFF) + end + status:SetText(text) + stack:Layout() + end + + FW:On("CODEX_SYNC_CHANGED", function() + if f:IsVisible() then + f.Refresh() + end + end, f) + + stack:Layout() + return f +end + +UI:RegisterSettingsSection("codex-share", { + order = 105, + height = 360, + build = Build, + refresh = function(frame) + frame.Refresh() + end, +}) diff --git a/CodexSpots.lua b/CodexSpots.lua new file mode 100644 index 0000000..1b1f5f6 --- /dev/null +++ b/CodexSpots.lua @@ -0,0 +1,322 @@ +-- Freewood-OctoWoW-Codex: Ortspunkte (Eingang zu Instanz oder Hoehle, Berg-Aufstieg, Engstelle, Zielpunkt) +-- +-- Berechtigte (Modul Freewood-OctoWoW-Dev, Rolle Bearbeiter oder hoeher) erfassen einzelne Punkte, an denen der Levelplan vorbeimuss: +-- zum Beispiel der Eingang einer Instanz fuer eine Dungeon-Quest. Ein Punkt gehoert zu einer oder mehreren Quests (q) und/oder +-- zu einem ganzen Gebiet (am = Karte; negativ = Instanz). Gespeichert wird in db.spots[id]; das normale Teilen verschickt sie als +-- Datensatz "h", angenommen nur von Absendern, die selbst berechtigt sind. +-- +-- db.spots[id] = { k = Art, f = "A" | "H" | nil, n = Name, p = { m, x, y }, q = { Quest-IDs }, am = Gebiet, del, upd } +-- Die ID ist ein Pruefwert aus Fraktion und Ort (auf etwa 0,5 % gerundet): wer an derselben Stelle erneut speichert, aendert den Punkt. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Spots = { draft = { q = {} }, rev = 0 } +Codex.Spots = Spots +NS.Spots = Spots + +Spots.KINDS = { "instance", "cave", "mountain", "pass", "point", "other" } +local KIND_OK = {} +for _, k in ipairs(Spots.KINDS) do KIND_OK[k] = true end +Spots.MAX_QUESTS = 20 + +local function Now() return _G.time and _G.time() or 0 end + +local function DB() + local db = Codex.db + if not db then return nil end + db.spots = db.spots or {} + return db +end + +local function CanEdit() + return Codex.Trust ~= nil and Codex.Trust.CanEdit() +end + +local function Round(v, f) return math.floor(v * f + 0.5) end + +local function Hash(text) + local h = 5381 + for i = 1, #text do h = (h * 33 + text:byte(i)) % 99999989 end + return h +end + +function Spots.Id(rec) + local p = rec.p or {} + return Hash(table.concat({ "s", tostring(rec.f or "-"), tostring(p.m), Round(p.x or 0, 200), Round(p.y or 0, 200) }, "|")) +end + +-- Aenderung melden (auch fuer empfangene Datensaetze: ruft CodexShare auf) +function Spots.Bump(id) + Spots.rev = Spots.rev + 1 + Spots.index = nil + FW:Fire("CODEX_SPOTS_CHANGED", id) +end + +------------------------------------------------------------------------ +-- Entwurf +------------------------------------------------------------------------ + +function Spots.Draft() + return Spots.draft +end + +function Spots.Reset() + local d = Spots.draft + d.p, d.am, d.kind, d.faction, d.name, d.editing = nil, nil, nil, nil, nil, nil + d.q = {} + FW:Fire("CODEX_SPOTS_DRAFT") +end + +-- Ort des Spielers { m, x, y } oder nil +function Spots.Here() + local T = Codex.Transport + if T and T.Here then return T.Here() end + return nil +end + +function Spots.Capture() + local here = Spots.Here() + if not here then return false end + Spots.draft.p = here + FW:Fire("CODEX_SPOTS_DRAFT") + return true +end + +-- Gebiet, in dem der Spieler gerade steht: Instanz (negativ) oder Karte +function Spots.AreaHere() + local inst = Codex.CurrentInstance and Codex.CurrentInstance() + if inst then return -inst end + local here = Spots.Here() + return here and here.m or nil +end + +function Spots.SetArea(am) + Spots.draft.am = am + FW:Fire("CODEX_SPOTS_DRAFT") +end + +function Spots.AddQuest(id) + id = tonumber(id) + local d = Spots.draft + if not id or id < 1 or id ~= math.floor(id) or #d.q >= Spots.MAX_QUESTS then return false end + for _, v in ipairs(d.q) do if v == id then return true end end + d.q[#d.q + 1] = id + FW:Fire("CODEX_SPOTS_DRAFT") + return true +end + +function Spots.RemoveQuest(id) + local d = Spots.draft + for i, v in ipairs(d.q) do + if v == id then table.remove(d.q, i) FW:Fire("CODEX_SPOTS_DRAFT") return true end + end + return false +end + +-- Quests zu einem Suchtext (Nummer oder Teil des Namens): { { id, title } }, hoechstens limit +function Spots.FindQuests(text, limit) + limit = limit or 8 + local out = {} + text = tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "") + if text == "" then return out end + local num = tonumber(text) + local seed = Codex.Seed and Codex.Seed.Ready and Codex.Seed.Ready() and Codex.Seed or nil + local V = Codex.V and Codex.V() + local function Title(id) + return Codex.QuestTitle(V and V.quests[id], id) + end + if num then + out[1] = { id = num, title = Title(num) } + return out + end + local needle = text:lower() + local seen = {} + for id, q in pairs(V and V.quests or {}) do + local t = Title(id) + if t and t:lower():find(needle, 1, true) then + seen[id] = true + out[#out + 1] = { id = id, title = t } + end + end + if seed and seed.IDs then + for _, id in ipairs(seed.IDs("quests")) do + if not seen[id] and seed.Haystack("quests", id):find(needle, 1, true) then + out[#out + 1] = { id = id, title = Title(id) } + end + end + 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) + while #out > limit do out[#out] = nil end + return out +end + +-- Entwurf pruefen. Rueckgabe: true oder false, Grund +function Spots.CheckDraft() + local d = Spots.draft + if not d.p then return false, "pos" end + if not KIND_OK[d.kind or "instance"] then return false, "kind" end + if not d.am and #d.q == 0 then return false, "link" end + return true +end + +------------------------------------------------------------------------ +-- Speichern, Bearbeiten, Loeschen +------------------------------------------------------------------------ + +local function Touch(id) + if Codex.Share and Codex.Share.InvalidateScan then Codex.Share.InvalidateScan() end + Spots.Bump(id) + FW:Fire("CODEX_CHANGED", "spots", id) +end + +local function Describe(rec) + local M = Codex.MapName + return string.format("%s: %s (%.1f / %.1f)%s", tostring(rec.k), M and M(rec.p.m) or rec.p.m, rec.p.x * 100, rec.p.y * 100, + rec.am and (" > " .. (M and M(rec.am) or rec.am)) or "") +end + +local function Log(id, field, old, new) + if Codex.DevLog then + pcall(Codex.DevLog, { who = Codex.Trust and Codex.Trust.SelfKey() or "?", kind = "h", id = id, field = field, old = old, new = new }) + end +end + +-- Rueckgabe: id oder nil, Grund +function Spots.Save() + if not CanEdit() then return nil, "rights" end + local ok, why = Spots.CheckDraft() + if not ok then return nil, why end + local d = Spots.draft + local q = {} + for _, v in ipairs(d.q) do q[#q + 1] = v end + table.sort(q) + local name = type(d.name) == "string" and d.name:sub(1, 40) or "" + local rec = { + k = d.kind or "instance", f = (d.faction == "A" or d.faction == "H") and d.faction or nil, + n = name ~= "" and name or nil, p = { m = d.p.m, x = d.p.x, y = d.p.y }, q = q, am = d.am, upd = Now(), + } + local db = DB() + local id = Spots.Id(rec) + local old = db.spots[id] + rec.upd = math.max(rec.upd, (old and old.upd or 0) + 1) + db.spots[id] = rec + Log(id, "add", old and not old.del and Describe(old) or nil, Describe(rec)) + Touch(id) + Spots.Reset() + return id +end + +-- Liste { id, k, f, n, p, q, am, upd, del }; je ID der neueste Stand; geloeschte nur mit withDeleted +function Spots.All(withDeleted) + local best = {} + local db = DB() + if not db then return {} end + for id, rec in pairs(db.spots) do + if type(rec) == "table" and rec.p then best[id] = rec end + end + if Codex.Share and Codex.Share.RemoteRecords then + for _, rec in ipairs(Codex.Share.RemoteRecords("h")) do + local cur = best[rec.id] + if not cur or (rec.upd or 0) > (cur.upd or 0) then best[rec.id] = rec end + end + end + local out = {} + for id, rec in pairs(best) do + if withDeleted or not rec.del then + out[#out + 1] = { id = id, k = rec.k, f = rec.f, n = rec.n, p = rec.p, q = rec.q or {}, am = rec.am, upd = rec.upd, del = rec.del } + end + end + table.sort(out, function(x, y) + if x.p.m ~= y.p.m then return x.p.m < y.p.m end + return x.id < y.id + end) + return out +end + +function Spots.Get(id) + for _, r in ipairs(Spots.All(true)) do + if r.id == id then return r end + end + return nil +end + +-- Punkt in den Entwurf laden (zum Aendern); Speichern an derselben Stelle ersetzt ihn +function Spots.Edit(id) + local r = Spots.Get(id) + if not r or r.del then return false end + local d = Spots.draft + d.p = { m = r.p.m, x = r.p.x, y = r.p.y } + d.kind, d.faction, d.name, d.am, d.editing = r.k, r.f, r.n, r.am, id + d.q = {} + for _, v in ipairs(r.q) do d.q[#d.q + 1] = v end + FW:Fire("CODEX_SPOTS_DRAFT") + return true +end + +function Spots.Delete(id) + if not CanEdit() then return false end + local rec = Spots.Get(id) + if not rec then return false end + local db = DB() + db.spots[id] = { k = rec.k, f = rec.f, n = rec.n, p = rec.p, q = rec.q, am = rec.am, del = true, + upd = math.max(Now(), (rec.upd or 0) + 1) } + Log(id, "del", Describe(rec), nil) + Touch(id) + return true +end + +------------------------------------------------------------------------ +-- Lesen fuer den Levelplan +------------------------------------------------------------------------ + +local EMPTY = {} + +local function BuildIndex() + local idx = { quest = {}, area = {} } + for _, r in ipairs(Spots.All()) do + local s = { id = r.id, k = r.k, f = r.f, n = r.n, m = r.p.m, x = r.p.x, y = r.p.y } + for _, qid in ipairs(r.q) do + idx.quest[qid] = idx.quest[qid] or {} + table.insert(idx.quest[qid], s) + end + if r.am then + idx.area[r.am] = idx.area[r.am] or {} + table.insert(idx.area[r.am], s) + end + end + idx.empty = next(idx.quest) == nil and next(idx.area) == nil + return idx +end + +-- Punkte, die fuer ein Ziel gelten: Quest-ID (oder nil) und Karte des Ziels (Instanzen negativ); faction = "A" | "H" | nil. +-- Rueckgabe: Liste { id, k, n, m, x, y } (leer, wenn nichts passt) +function Spots.For(questID, m, faction) + if not Spots.index then Spots.index = BuildIndex() end + local idx = Spots.index + if idx.empty then return EMPTY end + local out, seen = {}, {} + local function Add(list) + for _, s in ipairs(list or {}) do + if not seen[s.id] and not (faction and s.f and s.f ~= faction) then + seen[s.id] = true + out[#out + 1] = s + end + end + end + if questID then Add(idx.quest[questID]) end + if m then Add(idx.area[m]) end + return out +end + +function Spots.Count() + return #Spots.All() +end diff --git a/CodexSync.lua b/CodexSync.lua new file mode 100644 index 0000000..da5a67e --- /dev/null +++ b/CodexSync.lua @@ -0,0 +1,1247 @@ +-- Freewood-OctoWoW-Codex: Daten mit anderen Spielern teilen (Addon-Nachrichten) +-- +-- EIN Schalter (db.settings.share). Eingeschaltet wird ueber Gilde, Gruppe, Schlachtzug und einen +-- geheimen Kanal ("FreewoodCodex", aus allen Chatfenstern ausgeblendet) gerufen und getauscht. +-- Gesendet werden nur eigene Beobachtungen, keine Charakternamen, keine persoenlichen Angaben. +-- +-- Ablauf (alles "ziehen", nie ungefragt grosse Datenmengen schieben): +-- H Hallo mit Anzahl und Pruefwert der eigenen Datensaetze (Kanal) +-- A Ankuendigung geaenderter Datensaetze schluessel:stand,... (Kanal) +-- Q Anfrage nach dem Verzeichnis ab einem Stand (Fluestern) +-- V Verzeichnis schluessel:stand,... in Stuecken (Fluestern) +-- R Anfrage nach bestimmten Datensaetzen (Fluestern) +-- B Datensatz in Stuecken (Fluestern) +-- Empfangenes wird streng geprueft (Share.Sanitize) und getrennt je Absender gespeichert. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +local Share = NS and NS.Share +if not FW or not Codex or not Share then + return +end + +local Sync = { started = false } +NS.Sync = Sync +Codex.Sync = Sync + +local PREFIX = "FWCodex1" +local GLOBAL_CHANNEL = "FreewoodCodex" +local MAXLEN = 230 +local SEND_INTERVAL = 0.25 -- Takt des Zeitgebers +local MIN_INTERVAL = 3 -- kleinster Abstand zwischen zwei Nachrichten (Sekunden) +local MAX_INTERVAL = 6 +local GLOBAL_INTERVAL = 1.5 +local MAX_QUEUE = 500 +local MAX_CHUNKS = 40 +local MAX_BUFFERS = 6 +local SESSION_TTL = 600 +local DIGEST_COOLDOWN = 60 -- ein Partner darf unser Verzeichnis jede Minute holen +local SERVE_WINDOW = 300 +local MAX_SERVE = 150 -- Datensaetze je Partner und Zeitraum +local MAX_REQUEST = 50 -- Datensaetze je Abgleich +local MAX_DIGEST = 400 +local MAX_SESSIONS_HOUR = 12 +local MAX_RECEIVED_HOUR = 400 +local ANNOUNCE_EVERY = 60 +local ANNOUNCE_MIN = 300 +local SCAN_EVERY = 300 +local KINDS = { "guild", "party", "raid", "global" } + +local state = { + queue = {}, + peers = {}, -- [name] = { kind, t, hash, count, queried } + sessions = {}, -- [name] = { t, digest, got } + served = {}, -- [name] = { t, count, dirT } + buffers = {}, -- [name:key] = { t, n, parts, got } + received = {}, -- [name] = { t, count } + pending = {}, -- [key] = true: zu verkuendende Datensaetze + announced = {}, -- [key] = zeitpunkt + sessionLog = {}, + lastGlobalSend = 0, + rawNames = {}, + routes = {}, -- [name] = Art des Kanals, ueber den wir ihn zuletzt gehoert haben + served_partners = {}, -- [name] = zeitpunkt der ersten Bedienung in der Stunde + lastReply = 0, + interval = MIN_INTERVAL, + nextSend = 0, + log = false, + status = { global = "off" }, + stats = { txType = {}, rxType = {}, throttled = 0, fail = 0, lastResult = "-", rx = 0, tx = 0, hello = 0, helloIn = 0, queries = 0, served = 0, rejected = 0, byChannel = {} }, +} +Sync.state = state + +local function Now() + return (GetTime and GetTime()) or time() +end + +local function Store() + return Share.Store() +end + +function Sync:IsEnabled() + return Codex.db ~= nil and Codex.db.settings ~= nil and Codex.db.settings.share == true +end + +------------------------------------------------------------------------ +-- Kleine Helfer +------------------------------------------------------------------------ + +local B36 = "0123456789abcdefghijklmnopqrstuvwxyz" + +local function ToB36(n) + n = math.floor(n or 0) + if n <= 0 then + return "0" + end + local out = "" + while n > 0 do + local r = n % 36 + out = B36:sub(r + 1, r + 1) .. out + n = (n - r) / 36 + end + return out +end + +local function FromB36(s) + if type(s) ~= "string" or s == "" or #s > 8 or s:find("[^0-9a-z]") then + return nil + end + local n = 0 + for i = 1, #s do + n = n * 36 + (B36:find(s:sub(i, i), 1, true) - 1) + end + return n +end + +local function Clean(sender) + if type(sender) ~= "string" then + return nil + end + if Ambiguate then + local ok, v = pcall(Ambiguate, sender, "none") + if ok and v then + return v + end + end + return sender +end + +local function KeyOf(sender) + local c = Clean(sender) + return c and c:lower() or nil +end + +-- Name ohne Realm, ohne Leerzeichen (auch geschuetzte), kleingeschrieben; Namen koennen Leerzeichen enthalten +local function NameCore(s) + if type(s) ~= "string" then + return "" + end + s = s:gsub("\194\160", " "):match("^([^%-]*)") or s + s = s:gsub("%s", ""):lower() + return s +end + +-- Eigene Namen: alle Schreibweisen, die das Spiel liefert, plus Namen, die wir als Echo unserer eigenen Nachrichten erkannt haben +local learnedSelf = {} +local SESSION_NONCE = string.format("%06d", math.random(0, 999999)) .. string.format("%03d", math.random(0, 999)) +local function SelfCores() + local out = {} + local function add(v) + local c = NameCore(v) + if c ~= "" then + out[c] = true + end + end + if UnitName then + add((UnitName("player"))) + end + if UnitFullName then + local ok, n = pcall(UnitFullName, "player") + if ok then add(n) end + end + if GetUnitName then + local ok, n = pcall(GetUnitName, "player", false) + if ok then add(n) end + end + for c in pairs(learnedSelf) do + out[c] = true + end + return out +end + +local function IsSelf(sender) + if type(sender) ~= "string" then + return false + end + local c = NameCore(sender) + return c ~= "" and SelfCores()[c] == true +end + +local function GetEncoder() + local E = _G.C_EncodingUtil + if E and E.SerializeCBOR and E.DeserializeCBOR and E.CompressString + and E.DecompressString and E.EncodeBase64 and E.DecodeBase64 then + return E + end + return nil +end + +local function EncodeRecord(rec) + local E = GetEncoder() + if not E then + return nil + end + local ok, cbor = pcall(E.SerializeCBOR, rec) + if not ok or type(cbor) ~= "string" then + return nil + end + local ok2, packed = pcall(E.CompressString, cbor) + if not ok2 or type(packed) ~= "string" then + return nil + end + local ok3, body = pcall(E.EncodeBase64, packed) + if not ok3 or type(body) ~= "string" then + return nil + end + return body +end + +-- Eigener, nachsichtiger Base64-Leser: jede Vierergruppe fuer sich (auch wenn mitten im Text Auffuellzeichen stehen) +local B64CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +local B64IDX +local function LenientB64(text) + if not B64IDX then + B64IDX = {} + for i = 1, #B64CHARS do B64IDX[B64CHARS:sub(i, i)] = i - 1 end + end + local out = {} + for i = 1, #text, 4 do + local g = text:sub(i, i + 3) + local v, n = 0, 0 + for j = 1, 4 do + local c = g:sub(j, j) + local x = B64IDX[c] + if x then v = v * 64 + x n = n + 1 elseif c == "=" or c == "" then v = v * 64 else return nil end + end + if n < 2 then return nil end + local b1, b2, b3 = math.floor(v / 65536) % 256, math.floor(v / 256) % 256, v % 256 + out[#out + 1] = string.char(b1) + if n >= 3 then out[#out + 1] = string.char(b2) end + if n >= 4 then out[#out + 1] = string.char(b3) end + end + return table.concat(out) +end + +-- Diagnose (wird gespeichert, damit man nachsehen kann, woran ein Empfang scheitert) +local function Diag(stage, detail) + local st = Store() + st.diag = st.diag or {} + st.diag[stage] = (st.diag[stage] or 0) + 1 + if detail then st.diag.last = stage .. ": " .. tostring(detail):sub(1, 120) end +end + +-- Rueckgabe: Datensatz | nil, Stufe +local function DecodeRecord(body) + local E = GetEncoder() + if not E then return nil, "noapi" end + if type(body) ~= "string" or body:find("[^%w%+/=]") then return nil, "chars" end + local function Try(packed) + if type(packed) ~= "string" then return nil, "base64" end + local ok2, cbor = pcall(E.DecompressString, packed) + if not ok2 or type(cbor) ~= "string" then return nil, "decompress", cbor end + local ok3, rec = pcall(E.DeserializeCBOR, cbor) + if not ok3 or type(rec) ~= "table" then return nil, "cbor", rec end + if FW.Export and FW.Export.CheckStructure and not FW.Export.CheckStructure(rec) then return nil, "structure" end + return rec + end + local ok, packed = pcall(E.DecodeBase64, body) + local rec, stage, err = Try(ok and packed or nil) + if rec then return rec end + -- zweiter Versuch mit dem nachsichtigen Leser + local rec2, stage2, err2 = Try(LenientB64(body)) + if rec2 then Diag("lenient") return rec2 end + return nil, stage2 or stage, err2 or err +end + +-- Roundtrip-Test der Kodierung (Befehl /fwcodex enc): zeigt jede Stufe, Ergebnis wird gespeichert +function Sync:EncTest() + local E = GetEncoder() + local st = Store() + local out = {} + local function Add(t) out[#out + 1] = t FW:Print("Codex-Test: " .. t) end + if not E then Add("C_EncodingUtil nicht vollstaendig vorhanden") st.enctest = out return end + local sample = { y = "u", id = 80117, ne = "Haywire Battlechicken", k = 3, l1 = 2, l2 = 2, d = { [80159] = { q = 1, n = 1 } } } + local function Head(v) return type(v) == "string" and (#v .. " Zeichen, Anfang " .. v:sub(1, 24):gsub("[^%w%+/=]", "?")) or type(v) end + local ok1, cbor = pcall(E.SerializeCBOR, sample) + Add("CBOR: " .. tostring(ok1) .. " " .. Head(cbor)) + local ok2, packed = pcall(E.CompressString, cbor) + Add("Komprimiert: " .. tostring(ok2) .. " " .. Head(packed)) + local ok3, body = pcall(E.EncodeBase64, packed) + Add("Base64: " .. tostring(ok3) .. " " .. Head(body)) + local rec, stage, err = DecodeRecord(body) + Add("Zurueck: " .. (rec and ("ok, ne=" .. tostring(rec.ne) .. ", id=" .. tostring(rec.id)) or ("FEHLER bei " .. tostring(stage) .. " " .. tostring(err)))) + -- Teilstuecke wie beim Senden zusammensetzen + local parts, size = {}, 40 + if type(body) == "string" then + for i = 1, #body, size do parts[#parts + 1] = body:sub(i, i + size - 1) end + local rec2, stage2 = DecodeRecord(table.concat(parts)) + Add("Aus " .. #parts .. " Stuecken: " .. (rec2 and "ok" or ("FEHLER bei " .. tostring(stage2)))) + end + local direct = pcall(E.DecodeBase64, body) + Add("DecodeBase64 allein: " .. tostring(direct)) + st.enctest = out +end + +------------------------------------------------------------------------ +-- Senden mit Warteschlange +------------------------------------------------------------------------ + +local function Log(arrow, where, who, text) + if state.log then + FW:Print(string.format("|cff88ccffCodex %s|r %s%s %s", arrow, where or "?", who and (" " .. tostring(who):gsub("|", "||")) or "", ((text or ""):sub(1, 110):gsub("|", "||")))) + end +end + +-- Rueckgabe: gesendet, gebremst (das Spiel hat die Nachricht wegen zu hoher Rate abgelehnt) +local function Raw(dist, target, text) + local api = _G.C_ChatInfo + if api and api.SendAddonMessage then + local ok, res = pcall(api.SendAddonMessage, PREFIX, text, dist, target) + state.stats.lastResult = tostring(ok and res or "Fehler") + if ok and res ~= false and (type(res) ~= "number" or res == 0) then + state.stats.tx = state.stats.tx + 1 + local t = text:sub(1, 1) + state.stats.txType[t] = (state.stats.txType[t] or 0) + 1 + Log("->", dist .. (target and ("#" .. tostring(target)) or ""), nil, text) + return true, false + end + if ok and (res == 3 or res == 8) then + state.stats.throttled = state.stats.throttled + 1 + Log("!!", "gebremst", nil, text) + return false, true + end + state.stats.fail = state.stats.fail + 1 + return false, false + end + return false, false +end + +local function Enqueue(item) + local q = state.queue + if #q >= MAX_QUEUE then + for i = 1, #q do + local t = q[i].text:sub(1, 1) + if t == "H" or t == "A" then + table.remove(q, i) + break + end + end + if #q >= MAX_QUEUE then + return false + end + end + q[#q + 1] = item + return true +end + +function Sync:Pump() + local q = state.queue + if #q == 0 then + return false + end + local now = Now() + if now < state.nextSend then + return false + end + for i = 1, #q do + local item = q[i] + if not (item.dist == "CHANNEL" and now - state.lastGlobalSend < GLOBAL_INTERVAL) then + table.remove(q, i) + if item.dist == "CHANNEL" then + state.lastGlobalSend = now + end + local sent, throttled = Raw(item.dist, item.target, item.text) + if throttled then + -- zu schnell: Nachricht behalten, Abstand vergroessern + item.tries = (item.tries or 0) + 1 + if item.tries <= 8 then + table.insert(q, 1, item) + end + state.interval = math.min(MAX_INTERVAL, state.interval * 1.6) + else + state.interval = math.max(MIN_INTERVAL, state.interval * 0.95) + end + state.nextSend = now + state.interval + return true + end + end + return false +end + +-- Antwort an einen Spieler: ueber denselben Kanal, auf dem wir ihn gehoert haben, mit seinem Namen +-- als Empfaenger in der Nachricht (andere ignorieren sie). Fluestern gibt es bewusst nicht. +local function Reply(key, text) + local kind = state.routes[key] or (state.peers[key] and state.peers[key].kind) + if not kind then + return false + end + local dist, target = Sync:Distribution(kind) + if not dist then + return false + end + local who = state.rawNames[key] or key + return Enqueue({ dist = dist, target = target, text = text:sub(1, 1) .. "|" .. who .. text:sub(2) }) +end + +local function InstanceGroup() + return _G.LE_PARTY_CATEGORY_INSTANCE and IsInGroup and IsInGroup(_G.LE_PARTY_CATEGORY_INSTANCE) +end + +function Sync:Distribution(kind) + -- OctoWoW: nur der eigene Kanal (Gilde/Gruppe/Schlachtzug werden nicht benutzt) + if kind ~= "global" then return nil end + if kind == "guild" then + if IsInGuild and IsInGuild() then + return "GUILD" + end + elseif kind == "party" then + if InstanceGroup() then + return "INSTANCE_CHAT" + end + if IsInGroup and IsInGroup() and not (IsInRaid and IsInRaid()) then + return "PARTY" + end + elseif kind == "raid" then + if IsInRaid and IsInRaid() then + if InstanceGroup() then + return "INSTANCE_CHAT" + end + return "RAID" + end + elseif kind == "global" then + if GetChannelName then + local id = GetChannelName(GLOBAL_CHANNEL) + if type(id) == "number" and id > 0 then + return "CHANNEL", id + end + end + end + return nil +end + +local function Broadcast(text) + local used, sent = {}, 0 + for _, kind in ipairs(KINDS) do + local dist, target = Sync:Distribution(kind) + if dist then + local key = dist .. tostring(target or "") + if not used[key] then + used[key] = true + Enqueue({ dist = dist, target = target, text = text }) + sent = sent + 1 + end + end + end + return sent +end + +------------------------------------------------------------------------ +-- Hallo und Ankuendigungen +------------------------------------------------------------------------ + +-- Eigentuemer: Berechtigungsliste an alle schicken (hoechstens alle 5 Minuten, force = sofort) +local lastTrustSend = 0 +function Sync:SendTrust(force) + if not self:IsEnabled() or not Codex.Trust or not Codex.Trust.IsSelfOwner() then + return false + end + if not force and Now() - lastTrustSend < 300 then + return false + end + lastTrustSend = Now() + local sent = 0 + for _, line in ipairs(Codex.Trust.Chunks()) do + sent = sent + Broadcast(line) + end + return sent > 0 +end + +function Sync:SendHello() + if not self:IsEnabled() then + return false + end + local n, h = Share.Summary() + state.stats.hello = state.stats.hello + 1 + if next(learnedSelf) == nil then + Broadcast("E|" .. SESSION_NONCE) + end + return Broadcast(string.format("H|%d|%d", h, n)) > 0 +end + +local function SessionAllowed() + local now = Now() + local log = state.sessionLog + for i = #log, 1, -1 do + if now - log[i] > 3600 then + table.remove(log, i) + end + end + return #log < MAX_SESSIONS_HOUR +end + +function Sync:QueueAnnounce(key) + if self:IsEnabled() and type(key) == "string" then + state.pending[key] = true + end +end + +function Sync:OnChanged(kind, id) + if not self:IsEnabled() then + return + end + local key, _, changed = Share.StampOne(kind, id) + if key and changed then + self:QueueAnnounce(key) + end +end + +function Sync:FlushAnnounce() + if not self:IsEnabled() then + state.pending = {} + return 0 + end + local now = Now() + local keys = {} + for key in pairs(state.pending) do + if now - (state.announced[key] or 0) >= ANNOUNCE_MIN then + keys[#keys + 1] = key + end + end + table.sort(keys) + local parts, count = {}, 0 + local function flush() + if #parts > 0 then + Broadcast("A|" .. table.concat(parts, ",")) + parts = {} + end + end + for _, key in ipairs(keys) do + state.pending[key] = nil + local upd = Share.OwnUpd(key) + if upd then + state.announced[key] = now + parts[#parts + 1] = string.format("%s:%s", key, ToB36(upd)) + count = count + 1 + if #parts >= 12 then + flush() + end + end + end + flush() + return count +end + +-- Eigene Daten in Abstaenden durchsehen (erfasst auch Aenderungen ohne Ereignis) +function Sync:ScanOwn() + if not self:IsEnabled() then + return + end + local _, _, _, changed = Share.Scan(true) + for _, key in ipairs(changed) do + state.pending[key] = true + end +end + +------------------------------------------------------------------------ +-- Verzeichnis und Datensaetze +------------------------------------------------------------------------ + +local function PackList(prefix, entries) + local msgs, current, len = {}, {}, 0 + for _, e in ipairs(entries) do + local add = #e + 1 + if len + add > MAXLEN - 12 and #current > 0 then + msgs[#msgs + 1] = table.concat(current, ",") + current, len = {}, 0 + end + current[#current + 1] = e + len = len + add + end + if #current > 0 then + msgs[#msgs + 1] = table.concat(current, ",") + end + local out = {} + for i, body in ipairs(msgs) do + out[i] = string.format("%s|%d/%d|%s", prefix, i, #msgs, body) + end + return out +end + +local function ParseEntries(body) + local out = {} + if type(body) ~= "string" then + return out + end + for key, upd in body:gmatch("([uoqixtpzghf]%d+):([0-9a-z]+)") do + local nupd = FromB36(upd) + if nupd and nupd > 0 and #key <= 16 and #out < MAX_DIGEST then + out[#out + 1] = { key = key, upd = nupd } + end + end + return out +end + +-- Jeder darf Daten holen (es sind nur oeffentliche Spieldaten), aber nur wenige Partner je Stunde +function Sync:MayServe(name) + local now = Now() + local list = state.served_partners + if list[name] and now - list[name] < 3600 then + return true + end + local count = 0 + for key, t in pairs(list) do + if now - t >= 3600 then + list[key] = nil + else + count = count + 1 + end + end + if count >= 30 then + state.stats.rejected = state.stats.rejected + 1 + return false + end + list[name] = now + return true +end + +function Sync:SendDigest(name, since) + local dir = Share.Directory(since, MAX_DIGEST) + local flat = {} + for _, e in ipairs(dir) do + flat[#flat + 1] = string.format("%s:%s", e.key, ToB36(e.upd)) + end + for _, msg in ipairs(PackList("V", flat)) do + Reply(name, msg) + end + if #flat == 0 then + Reply(name, "V|1/1|") + end + return #flat +end + +-- Welche Datensaetze aus einer Liste fehlen uns (neuer Stand)? Aelteste zuerst. +local function Missing(name, entries) + local need = {} + for _, e in ipairs(entries) do + local have = Share.HaveRemote(name, e.key) + if not have or have < e.upd then + need[#need + 1] = e + end + end + table.sort(need, function(a, b) + if a.upd ~= b.upd then + return a.upd < b.upd + end + return a.key < b.key + end) + return need +end + +function Sync:SendRequest(name, need) + local ids = {} + for i, e in ipairs(need) do + if i > MAX_REQUEST then + break + end + ids[#ids + 1] = string.format("%s:%s", e.key, ToB36(e.upd)) + end + if #ids == 0 then + return 0 + end + for _, msg in ipairs(PackList("R", ids)) do + Reply(name, msg) + end + state.sessions[name] = state.sessions[name] or { t = Now() } + state.sessions[name].t = Now() + return #ids +end + +function Sync:ServeRecords(name, entries) + local now = Now() + local s = state.served[name] + if not s then + s = { t = now, count = 0 } + state.served[name] = s + elseif now - s.t > SERVE_WINDOW then + s.t, s.count = now, 0 + end + local sent = 0 + for _, e in ipairs(entries) do + if s.count >= MAX_SERVE then + break + end + local rec, upd = Share.OwnRecord(e.key) + if rec then + local body = EncodeRecord(rec) + local who = state.rawNames[name] or name + local size = MAXLEN - (2 + #who + 1 + #e.key + 1 + 8 + 1 + 8) + local n = body and math.ceil(#body / size) or 0 + -- zu grosse Datensaetze (viele Waren/Lehrstoff) schrittweise verkleinern, statt sie zu verlieren + local tries = 0 + while body and n > MAX_CHUNKS and tries < 5 do + tries = tries + 1 + rec = Share.Shrink(rec) + body = rec and EncodeRecord(rec) or nil + n = body and math.ceil(#body / size) or 0 + end + if body and n >= 1 and n <= MAX_CHUNKS then + for i = 1, n do + local piece = body:sub((i - 1) * size + 1, i * size) + Reply(name, string.format("B|%s|%s|%d/%d|%s", e.key, ToB36(upd), i, n, piece)) + end + s.count = s.count + 1 + sent = sent + 1 + Store().sent = (Store().sent or 0) + 1 + end + end + end + return sent +end + +local function ReceivedAllowed(name) + local now = Now() + local r = state.received[name] + if not r or now - r.t > 3600 then + r = { t = now, count = 0 } + state.received[name] = r + end + return r.count < MAX_RECEIVED_HOUR, r +end + +function Sync:ReceiveChunk(name, key, upd, i, n, piece) + local sess = state.sessions[name] + if not sess or Now() - sess.t > SESSION_TTL then + Diag("nosession", name) + return false + end + if not key or not upd or not i or not n or n < 1 or n > MAX_CHUNKS or i < 1 or i > n or #piece > MAXLEN then + return false + end + local bkey = name .. ":" .. key .. ":" .. tostring(upd) + local buf = state.buffers[bkey] + if not buf then + local count = 0 + for _ in pairs(state.buffers) do + count = count + 1 + end + if count >= MAX_BUFFERS then + return false + end + buf = { t = Now(), n = n, parts = {}, got = 0 } + state.buffers[bkey] = buf + end + if buf.n ~= n then + state.buffers[bkey] = nil + return false + end + if not buf.parts[i] then + buf.parts[i] = piece + buf.got = buf.got + 1 + end + if buf.got < n then + return true + end + state.buffers[bkey] = nil + local ok, r = ReceivedAllowed(name) + if not ok then + Diag("limit", name) + return false + end + r.count = r.count + 1 + local raw, stage, err = DecodeRecord(table.concat(buf.parts)) + if not raw then + Diag("decode_" .. tostring(stage), err) + state.stats.rejected = state.stats.rejected + 1 + return false + end + local rec = Share.Sanitize(key, raw, upd) + if not rec then + Diag("sanitize", key) + state.stats.rejected = state.stats.rejected + 1 + return false + end + if Share.StoreRecord(name, rec) then + local store = Store() + store.received = (store.received or 0) + 1 + store.lastReceived = time() + FW:Fire("CODEX_SYNC_CHANGED") + else + Diag("notstored", key) + end + return true +end + +------------------------------------------------------------------------ +-- Empfang +------------------------------------------------------------------------ + +local function KindOf(channel) + if channel == "GUILD" then + return "guild" + elseif channel == "PARTY" then + return "party" + elseif channel == "RAID" then + return "raid" + elseif channel == "INSTANCE_CHAT" then + return "party" + elseif channel == "CHANNEL" then + return "global" + end + return nil +end + +function Sync:HandleMessage(channel, sender, text) + if not self:IsEnabled() then + return false + end + if type(text) ~= "string" or #text < 3 or #text > 255 then + return false + end + if type(sender) ~= "string" or sender == "" or IsSelf(sender) then + return false + end + local name = KeyOf(sender) + if not name or name == "" or name:sub(1, 1) == "~" then + return false + end + state.stats.rx = state.stats.rx + 1 + state.stats.byChannel[channel] = (state.stats.byChannel[channel] or 0) + 1 + state.rawNames[name] = sender + local t = text:sub(1, 1) + local body = text:sub(3) + local kind = KindOf(channel) + if kind and (t == "H" or t == "A") then + state.routes[name] = kind + end + + -- Berechtigungsliste vom Eigentuemer (wird nur von Eigentuemern uebernommen) + if t == "T" and kind then + if Codex.Trust then Codex.Trust.Receive(sender, body) end + return true + end + if kind then + if t == "H" then + local h, n = body:match("^(%d+)|(%d+)$") + h, n = tonumber(h), tonumber(n) + if not h or not n then + return false + end + state.stats.helloIn = state.stats.helloIn + 1 + local peer = state.peers[name] or {} + local newcomer = not state.peers[name] or Now() - state.peers[name].t > 600 + local known = Share.Sender(name, false) + state.peers[name] = { kind = kind, t = Now(), hash = h, count = n, queried = peer.queried } + -- Neuling gruesst zurueck: einmal antworten, damit er unsere Daten auch bekommt + if newcomer and Codex.Trust and Codex.Trust.IsSelfOwner() then + C_Timer.After(5 + math.random() * 10, function() Sync:SendTrust() end) + end + if newcomer and Now() - state.lastReply > 60 then + state.lastReply = Now() + C_Timer.After(3 + math.random() * 5, function() + Sync:SendHello() + end) + end + -- eine Anfrage gilt 10 Minuten, ohne Antwort schon nach 90 Sekunden nicht mehr + local sess = state.sessions[name] + local window = (sess and (sess.got or 0) == 0) and 90 or 600 + if n > 0 and not (known and known.hash == h) and not (peer.queried and Now() - peer.queried < window) and SessionAllowed() then + local active = 0 + for _, s in pairs(state.sessions) do + if Now() - s.t < 120 and s.digest then + active = active + 1 + end + end + if active < 2 then + state.peers[name].queried = Now() + state.sessionLog[#state.sessionLog + 1] = Now() + state.sessions[name] = { t = Now(), digest = {}, got = 0 } + local since = known and known.cursor or 0 + Reply(name, "Q|" .. ToB36(since)) + end + end + return true + elseif t == "A" then + state.peers[name] = { + kind = kind, t = Now(), hash = (state.peers[name] or {}).hash, queried = (state.peers[name] or {}).queried, + } + local need = Missing(name, ParseEntries(body)) + if #need > 0 and #need <= 12 then + self:SendRequest(name, need) + end + return true + elseif t == "Q" or t == "V" or t == "R" or t == "B" then + local target, rest = body:match("^([^|]+)|(.*)$") + if not target or not IsSelf(target) then + return false + end + state.routes[name] = kind + return self:HandleDirect(name, t, rest) + end + return false + end + return false +end + +-- Nachrichten an uns persoenlich (Q, V, R, B): name = Absender, t = Art, body = Inhalt +function Sync:HandleDirect(name, t, body) + if t == "Q" then + -- nur Partner bedienen, die uns ueber einen Kanal gerufen haben + if not self:MayServe(name) then + return false + end + state.stats.queries = state.stats.queries + 1 + local s = state.served[name] + if s and s.dirT and Now() - s.dirT < DIGEST_COOLDOWN then + return false + end + if not s then + s = { t = Now(), count = 0 } + state.served[name] = s + end + s.dirT = Now() + self:SendDigest(name, FromB36(body) or 0) + return true + elseif t == "V" then + local sess = state.sessions[name] + if not sess or not sess.digest or Now() - sess.t > SESSION_TTL then + return false + end + local i, n, list = body:match("^(%d+)/(%d+)|(.*)$") + i, n = tonumber(i), tonumber(n) + if not i or not n or n < 1 or n > 30 or i < 1 or i > n then + return false + end + for _, e in ipairs(ParseEntries(list)) do + if #sess.digest < MAX_DIGEST then + sess.digest[#sess.digest + 1] = e + end + end + sess.got = (sess.got or 0) + 1 + if sess.got >= n then + local digest = sess.digest + sess.digest = nil + table.sort(digest, function(a, b) + if a.upd ~= b.upd then return a.upd < b.upd end + return a.key < b.key + end) + local need = Missing(name, digest) + local first = {} + for idx = 1, math.min(#need, MAX_REQUEST) do + first[idx] = need[idx] + end + self:SendRequest(name, first) + -- Fortschritt merken: alles bis zum letzten angefragten Stand gilt als abgeholt + local sender = Share.Sender(name, true) + local mark + if #need > MAX_REQUEST then + mark = first[#first].upd + elseif #digest >= MAX_DIGEST then + mark = digest[#digest].upd + else + mark = nil + end + if mark then + sender.cursor = math.max(sender.cursor or 0, mark) + -- der Rest wird beim naechsten Hallo geholt + if state.peers[name] then + state.peers[name].queried = nil + end + else + if #digest > 0 then + sender.cursor = math.max(sender.cursor or 0, digest[#digest].upd) + end + sender.hash = state.peers[name] and state.peers[name].hash or sender.hash + end + end + return true + elseif t == "R" then + if not self:MayServe(name) then + return false + end + local _, _, list = body:match("^(%d+)/(%d+)|(.*)$") + self:ServeRecords(name, ParseEntries(list)) + return true + elseif t == "B" then + local key, upd, i, n, piece = body:match("^([uoqixtpzghf]%d+)|([0-9a-z]+)|(%d+)/(%d+)|(.+)$") + return self:ReceiveChunk(name, key, FromB36(upd), tonumber(i), tonumber(n), piece) + end + return false +end + +------------------------------------------------------------------------ +-- Geheimer Kanal +------------------------------------------------------------------------ + +function Sync:JoinGlobal() + if not self:IsEnabled() then + return + end + local id = GetChannelName and GetChannelName(GLOBAL_CHANNEL) + if type(id) == "number" and id > 0 then + state.status.global = "ok" + return + end + if type(JoinChannelByName) ~= "function" then + state.status.global = "unavailable" + return + end + local ok = pcall(JoinChannelByName, GLOBAL_CHANNEL) + if not ok then + state.status.global = "failed" + return + end + if type(ChatFrame_RemoveChannel) == "function" then + for i = 1, (_G.NUM_CHAT_WINDOWS or 10) do + local frame = _G["ChatFrame" .. i] + if frame then + pcall(ChatFrame_RemoveChannel, frame, GLOBAL_CHANNEL) + end + end + end + state.status.global = "joining" + C_Timer.After(3, function() + local cid = GetChannelName and GetChannelName(GLOBAL_CHANNEL) + state.status.global = (type(cid) == "number" and cid > 0) and "ok" or "failed" + if state.status.global == "ok" and Sync:IsEnabled() then + Sync:SendHello() + end + FW:Fire("CODEX_SYNC_CHANGED") + end) +end + +function Sync:LeaveGlobal() + if type(LeaveChannelByName) == "function" then + pcall(LeaveChannelByName, GLOBAL_CHANNEL) + end + state.status.global = "off" +end + +function Sync:SetEnabled(on) + Codex.db.settings.share = on and true or false + if on then + self:Start() + self:JoinGlobal() + C_Timer.After(2 + math.random() * 3, function() + Sync:ScanOwn() + Sync:SendHello() + end) + else + self:LeaveGlobal() + self:Stop() + end + FW:Fire("CODEX_SYNC_CHANGED") +end + +------------------------------------------------------------------------ +-- Start / Stopp und Zeitgeber +------------------------------------------------------------------------ + +local generation = 0 + +local function Loop(gen, delay, fn) + C_Timer.After(delay, function() + if not Sync.started or gen ~= generation then + return + end + fn() + Loop(gen, delay, fn) + end) +end + +function Sync:OnAddonMessage(prefix, text, channel, sender) + if prefix ~= PREFIX then + return + end + if type(text) == "string" then + local t = text:sub(1, 1) + state.stats.rxType[t] = (state.stats.rxType[t] or 0) + 1 + end + Log("<-", tostring(channel), tostring(sender), tostring(text)) + -- Probe: "E|" mit unserer Sitzungsnummer. Kommt sie zurueck, ist der Absender wir selbst + -- (so erfahren wir, unter welchem Namen uns die anderen sehen, auch mit Nachnamen) + if type(text) == "string" and text:sub(1, 2) == "E|" then + if text == "E|" .. SESSION_NONCE and type(sender) == "string" then + local c = NameCore(sender) + if c ~= "" and not learnedSelf[c] then + learnedSelf[c] = true + Log("ii", "eigener Name erkannt", sender, "") + end + end + return + end + self:HandleMessage(channel, sender, text) +end + +local groupHelloAt = 0 +function Sync:OnGroupChanged() + if not self:IsEnabled() or Now() - groupHelloAt < 120 then + return + end + groupHelloAt = Now() + C_Timer.After(8, function() + Sync:SendHello() + end) +end + +function Sync:Start() + if self.started or not self:IsEnabled() then + return + end + self.started = true + generation = generation + 1 + local gen = generation + local api = _G.C_ChatInfo + if api and api.RegisterAddonMessagePrefix then + pcall(api.RegisterAddonMessagePrefix, PREFIX) + end + FW:RegisterEvent("CHAT_MSG_ADDON", self.OnAddonMessage, self) + FW:RegisterEvent("GROUP_ROSTER_UPDATE", self.OnGroupChanged, self) + FW:On("CODEX_CHANGED", function(_, kind, id) + Sync:OnChanged(kind, id) + end, self) + Loop(gen, SEND_INTERVAL, function() + Sync:Pump() + local now = Now() + for key, buf in pairs(state.buffers) do + if now - buf.t > 60 then + state.buffers[key] = nil + end + end + end) + Loop(gen, ANNOUNCE_EVERY, function() + Sync:FlushAnnounce() + end) + Loop(gen, SCAN_EVERY, function() + Sync:ScanOwn() + end) + -- erstes Hallo nach dem Einloggen, verteilt, damit nicht alle gleichzeitig rufen + C_Timer.After(5, function() + if Sync.started and gen == generation then + Sync:JoinGlobal() + end + end) + C_Timer.After(10 + math.random() * 15, function() + if Sync.started and gen == generation then + Sync:JoinGlobal() + Share.Scan(true) + Sync:SendHello() + Loop(gen, 2700, function() + Sync:SendHello() + end) + end + end) +end + +function Sync:Stop() + self.started = false + generation = generation + 1 + FW:UnregisterEvent("CHAT_MSG_ADDON", self) + FW:UnregisterEvent("GROUP_ROSTER_UPDATE", self) + FW:Off("CODEX_CHANGED", self) + state.queue = {} + state.pending = {} +end + +function Sync:SetLog(on) + state.log = on and true or false + FW:Print("Codex-Protokoll: " .. (state.log and "an (-> gesendet, <- empfangen)" or "aus")) + FW:Fire("CODEX_SYNC_CHANGED") +end + +function Sync:HeardCount() + local n, now = 0, Now() + for _, p in pairs(state.peers) do + if now - p.t < 3600 then + n = n + 1 + end + end + return n +end + +function Sync:Debug() + local st = state.stats + local parts = {} + for ch, n in pairs(st.byChannel) do + parts[#parts + 1] = ch .. "=" .. n + end + table.sort(parts) + local dist = {} + for _, kind in ipairs(KINDS) do + dist[#dist + 1] = kind .. "=" .. tostring((self:Distribution(kind)) or "-") + end + local own = Share.Summary() + local ver = (C_AddOns and C_AddOns.GetAddOnMetadata and C_AddOns.GetAddOnMetadata("Freewood-OctoWoW-Codex", "Version")) or "?" + local me = {} + for c in pairs(SelfCores()) do me[#me + 1] = c end + table.sort(me) + FW:Print(string.format("Codex %s, ich bin: %s (erkannt als: %s)", tostring(ver), tostring(UnitName and UnitName("player")), table.concat(me, ", "))) + FW:Print(string.format("Codex-Teilen: %s, gestartet %s, Kanal %s, eigene Datensaetze %d", + self:IsEnabled() and "an" or "aus", tostring(self.started), tostring(state.status.global), own or 0)) + FW:Print("Wege: " .. table.concat(dist, " ")) + FW:Print(string.format("Gesendet %d (gebremst %d, fehlgeschlagen %d, Abstand %.1fs, letzte Antwort %s), empfangen %d (%s), Hallo aus %d, Hallo ein %d, Anfragen %d, abgelehnt %d, Warteschlange %d", + st.tx, st.throttled, st.fail, state.interval, st.lastResult, st.rx, table.concat(parts, " "), st.hello, st.helloIn, st.queries, st.rejected, #state.queue)) + local function Types(t) + local out = {} + for _, k in ipairs({ "H", "A", "Q", "V", "R", "B" }) do + out[#out + 1] = k .. "=" .. (t[k] or 0) + end + return table.concat(out, " ") + end + FW:Print("Gesendet nach Art: " .. Types(st.txType)) + FW:Print("Empfangen nach Art: " .. Types(st.rxType)) + FW:Print(string.format("Bekannte Spieler (letzte Stunde): %d", self:HeardCount())) +end + +function Sync:Stats() + local store = Store() + local senders, records = Share.RemoteStats() + return { + sent = store.sent or 0, + received = store.received or 0, + lastReceived = store.lastReceived, + queue = #state.queue, + global = state.status.global, + senders = senders, + records = records, + heard = Sync:HeardCount(), + } +end + +-- Wird vom Codex nach dem Laden der Daten aufgerufen. Ist der Schalter gesetzt, verbindet sich das +-- Teilen von selbst (auch nach /reload), ohne dass man den Haken neu setzen muss. +function Sync:Init() + if self:IsEnabled() then + self:Start() + end +end + +function Sync:OnEnteringWorld() + if self:IsEnabled() and C_Timer and C_Timer.After then + C_Timer.After(30, function() Sync:SendTrust() end) + end + if self:IsEnabled() then + if not self.started then + self:Start() + elseif state.status.global ~= "ok" then + C_Timer.After(5, function() + if Sync.started then + Sync:JoinGlobal() + end + end) + end + end +end + +FW:RegisterEvent("PLAYER_ENTERING_WORLD", Sync.OnEnteringWorld, Sync) diff --git a/CodexTaxi.lua b/CodexTaxi.lua new file mode 100644 index 0000000..051ab21 --- /dev/null +++ b/CodexTaxi.lua @@ -0,0 +1,505 @@ +-- Freewood-OctoWoW-Codex: Flugrouten (Taxi) +-- +-- Erfasst beim Flug: Start- und Zielpunkt, die Strecke (Punkte alle paar Sekunden), die Flugdauer und den bezahlten Preis. +-- Je Charakter: welche Flugpunkte er kennt, wie viel Gold er fuer Fluege ausgegeben hat und wie oft er geflogen ist. +-- +-- db.taxi.nodes[id] = { n = Name, w = Welt, tx, ty = Position auf der Flugkarte, a, b = Weltposition (erster, zweiter Wert von UnitPosition) } +-- db.taxi.routes[id] = { a = Start-ID, b = Ziel-ID, t = Sekunden, c = Preis in Kupfer, n = Anzahl, p = { a1, b1, a2, b2, ... }, upd = Stand } +-- db.taxiChar[Charakter] = { known = { [id] = true }, spent = Kupfer, trips = Anzahl, log = { { t, a, b, c, d }, ... } } +-- Die Flugpunkt-ID kommt aus der Position auf der Flugkarte (sprachunabhaengig), sonst aus dem Namen. +-- Zwischenlandungen: aufgezeichnet wird die Gesamtstrecke bis zum gewaehlten Ziel. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Taxi = { state = {} } +Codex.Taxi = Taxi +NS.Taxi = Taxi +local state = Taxi.state + +local SAMPLE = 3 -- Sekunden zwischen zwei Streckenpunkten +local MAX_POINTS = 120 -- Punkte je Route (je 2 Zahlen) +local MAX_ROUTES = 600 +local MAX_NODES = 300 +local MAX_LOG = 30 +local PRICE_WINDOW = 6 -- Sekunden nach dem Start, in denen der abgezogene Betrag nachgemessen wird + +local function Clock() return _G.GetTime and _G.GetTime() or 0 end +local function Now() return _G.time and _G.time() or 0 end +local function Round(v) return math.floor((v or 0) + 0.5) end + +local function DB() + local db = Codex.db + if not db then return nil end + local t = db.taxi + if type(t) ~= "table" then t = {}; db.taxi = t end + t.nodes = t.nodes or {} + t.routes = t.routes or {} + return t +end + +local function CharRec(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.taxiChar = db.taxiChar or {} + local r = db.taxiChar[key] + if not r and create ~= false then + r = { known = {}, spent = 0, trips = 0, log = {} } + db.taxiChar[key] = r + end + return r +end + +function Taxi.Money(c) + c = math.floor(tonumber(c) or 0) + local g, s, k = math.floor(c / 10000), math.floor(c / 100) % 100, c % 100 + local out = {} + if g > 0 then out[#out + 1] = g .. "g" end + if s > 0 then out[#out + 1] = s .. "s" end + if k > 0 or #out == 0 then out[#out + 1] = k .. "c" end + return table.concat(out, " ") +end + +function Taxi.Duration(sec) + sec = math.floor(tonumber(sec) or 0) + return string.format("%d:%02d", math.floor(sec / 60), sec % 60) +end + +------------------------------------------------------------------------ +-- Abfragen (fuer Karte und Planer) +------------------------------------------------------------------------ + +Taxi.rev = 0 +local merged + +-- Eigene und empfangene Routen zusammen (eigene haben Vorrang) +function Taxi.Routes() + local t = DB() + if not t then return {} end + local rem = Codex.db.taxir + if not rem or next(rem) == nil then return t.routes end + if merged and merged.rev == Taxi.rev then return merged.routes end + local out = {} + for k, r in pairs(rem) do out[k] = r end + for k, r in pairs(t.routes) do out[k] = r end + merged = { rev = Taxi.rev, routes = out } + return out +end + +function Taxi.Node(id) + local t = DB() + if not t then return nil end + local n = t.nodes[id] + if n then return n end + local rn = Codex.db.taxin + return rn and rn[id] or nil +end + +-- Kennt dieser Charakter den Flugpunkt? +function Taxi.Known(id) + local c = CharRec(false) + return c and c.known and c.known[id] and true or false +end + +function Taxi.CharStats() + local c = CharRec(false) + if not c then return nil end + local n = 0 + for _ in pairs(c.known or {}) do n = n + 1 end + return { known = n, spent = c.spent or 0, trips = c.trips or 0, secs = c.secs or 0, log = c.log, routes = c.rt } +end + +-- Wie oft dieser Charakter die Route geflogen ist und wie lange insgesamt: n, Sekunden +function Taxi.RouteStat(a, b) + local c = CharRec(false) + local r = c and c.rt and c.rt[a .. ">" .. b] + if not r then return 0, 0 end + return r.n or 0, r.sec or 0 +end + +------------------------------------------------------------------------ +-- Flugkarte lesen +------------------------------------------------------------------------ + +local function NodeId(i, world) + if _G.TaxiNodePosition then + local ok, x, y = pcall(_G.TaxiNodePosition, i) + if ok and type(x) == "number" and type(y) == "number" then + return string.format("%d:%d:%d", world or 0, Round(x * 1000), Round(y * 1000)), x, y + end + end + local name = _G.TaxiNodeName and _G.TaxiNodeName(i) + if type(name) == "string" and name ~= "" then return "n:" .. name, nil, nil end + return nil +end + +-- Kartenposition des Flugpunkts (Karte, x, y), solange der Spieler dort steht; fuer den Levelplaner +local function SetMapPos(n) + local loc = FW.Util and FW.Util.GetPlayerLocation and FW.Util.GetPlayerLocation() + if loc and loc.mapID and loc.x and loc.y and loc.x > 0 and loc.y > 0 then + if n.m ~= loc.mapID or not n.x then + n.m, n.x, n.y = loc.mapID, loc.x, loc.y + return true + end + end + return false +end + +function Taxi.Scan() + local count = _G.NumTaxiNodes and _G.NumTaxiNodes() or 0 + if type(count) ~= "number" or count <= 0 then return end + local T, C = DB(), CharRec(true) + if not T or not C then return end + local P = Codex.Path and Codex.Path.Read and Codex.Path.Read() + local world = P and P.world or 0 + local nodes = 0 + for _ in pairs(T.nodes) do nodes = nodes + 1 end + state.slot, state.cur = {}, nil + local changed = false + for i = 1, math.min(count, 120) do + local kind = _G.TaxiNodeGetType and _G.TaxiNodeGetType(i) + local name = _G.TaxiNodeName and _G.TaxiNodeName(i) + local id, tx, ty = NodeId(i, world) + if id then + local n = T.nodes[id] + if not n and nodes < MAX_NODES then + n = {} + T.nodes[id] = n + nodes = nodes + 1 + end + if n then + if type(name) == "string" and name ~= "" then n.n = name:sub(1, 60) end + n.w, n.tx, n.ty = world, tx, ty + end + if kind ~= "NONE" and not C.known[id] then C.known[id] = true; changed = true end + if kind == "CURRENT" then + state.cur = id + if n and P then n.a, n.b = Round(P.y), Round(P.x) end + if n and SetMapPos(n) then changed = true end + end + local cost = _G.TaxiNodeCost and tonumber(_G.TaxiNodeCost(i)) or nil + state.slot[i] = { id = id, cost = cost and math.floor(cost) or nil, kind = kind } + end + end + if changed then + Taxi.rev = Taxi.rev + 1 + FW:Fire("CODEX_TAXI_CHANGED") + end + if Taxi.OnOpen then pcall(Taxi.OnOpen) end +end + +------------------------------------------------------------------------ +-- Teilen (Share/Sync, Datensatzart "f", je Route ein Datensatz) +------------------------------------------------------------------------ +-- Zahl zur Route: Flugpunkt-Zahl (Welt, x, y) * 10^7 + Flugpunkt-Zahl des Ziels. Nur Flugpunkte mit Positions-ID sind teilbar. + +local NODE_W = 1002001 +local SHARE_POINTS = 40 + +local function NodeNum(id) + local w, x, y = tostring(id):match("^(%d+):(%d+):(%d+)$") + w, x, y = tonumber(w), tonumber(x), tonumber(y) + if not w or w > 8 or x > 1000 or y > 1000 then return nil end + return w * NODE_W + x * 1001 + y +end + +local function NodeFromNum(n) + if type(n) ~= "number" or n < 0 or n >= 9 * NODE_W then return nil end + local w, r = math.floor(n / NODE_W), n % NODE_W + return string.format("%d:%d:%d", w, math.floor(r / 1001), r % 1001) +end + +local function RouteNum(a, b) + local na, nb = NodeNum(a), NodeNum(b) + if not na or not nb then return nil end + return na * 10000000 + nb +end + +local function RouteIds(num) + local a, b = NodeFromNum(math.floor(num / 10000000)), NodeFromNum(num % 10000000) + if not a or not b then return nil end + return a, b +end + +function Taxi.SyncList() + local out = {} + local t = DB() + if not t then return out end + for _, r in pairs(t.routes) do + local num = r.upd and RouteNum(r.a, r.b) + if num then out[#out + 1] = { key = "f" .. string.format("%.0f", num), upd = r.upd } end + end + return out +end + +local function NodeShare(id) + local n = Taxi.Node(id) or {} + return { n = n.n, a = n.a, b = n.b, m = n.m, x = n.x, y = n.y } +end + +-- Eigene Route als Datensatz (Strecke auf hoechstens SHARE_POINTS Punkte verkleinert) +function Taxi.RouteRecord(num) + local a, b = RouteIds(num) + local t = DB() + local r = a and t and t.routes[a .. ">" .. b] + if not r or not r.upd or type(r.p) ~= "table" then return nil end + local pts, m = {}, math.floor(#r.p / 2) + if m < 2 then return nil end + local take = math.min(m, SHARE_POINTS) + for i = 1, take do + local j = (take == 1) and 1 or (1 + math.floor((i - 1) * (m - 1) / (take - 1) + 0.5)) + pts[#pts + 1] = r.p[j * 2 - 1] + pts[#pts + 1] = r.p[j * 2] + end + return { t = r.t, c = r.c, n = r.n or 1, p = pts, A = NodeShare(a), B = NodeShare(b) }, r.upd +end + +local function NodeFromRecord(id, rn) + local w, x, y = tostring(id):match("^(%d+):(%d+):(%d+)$") + return { n = rn.n, w = tonumber(w), tx = tonumber(x) / 1000, ty = tonumber(y) / 1000, a = rn.a, b = rn.b, m = rn.m, x = rn.x, y = rn.y } +end + +-- Empfangene Route einmischen. Rueckgabe: true, wenn gespeichert +function Taxi.StoreRemote(rec) + local db = Codex.db + if not db or type(rec) ~= "table" then return false end + local a, b = RouteIds(rec.id) + if not a then return false end + db.taxir = db.taxir or {} + db.taxin = db.taxin or {} + local rid = a .. ">" .. b + local old = db.taxir[rid] + if old and (old.upd or 0) >= rec.upd then return false end + if not old then + local count = 0 + for _ in pairs(db.taxir) do count = count + 1 end + if count >= MAX_ROUTES then return false end + end + db.taxir[rid] = { a = a, b = b, t = rec.t, c = rec.c, n = rec.n, p = rec.p, upd = rec.upd } + local own = DB() + for id, rn in pairs({ [a] = rec.A, [b] = rec.B }) do + if not (own and own.nodes[id]) and not db.taxin[id] then + local nodes = 0 + for _ in pairs(db.taxin) do nodes = nodes + 1 end + if nodes < MAX_NODES then db.taxin[id] = NodeFromRecord(id, rn) end + end + end + Taxi.rev = Taxi.rev + 1 + return true +end + +-- Levelplaner: Fluege als Kanten { id, k, t, wait, from = { m, x, y }, to = { m, x, y } }, nur zwischen Flugpunkten, +-- die dieser Charakter kennt und deren Kartenposition bekannt ist +function Taxi.PlannerOn() + local db = Codex.db + return not (db and db.settings and db.settings.taxiPlan == false) +end + +local edgeCache +function Taxi.Edges() + if not Taxi.PlannerOn() then return {} end + local key = Codex.Trust and Codex.Trust.SelfKey and Codex.Trust.SelfKey() or "?" + if edgeCache and edgeCache.rev == Taxi.rev and edgeCache.key == key then return edgeCache.list end + local list = {} + for rid, r in pairs(Taxi.Routes()) do + local na, nb = Taxi.Node(r.a), Taxi.Node(r.b) + if na and nb and na.m and nb.m and r.t and Taxi.Known(r.a) and Taxi.Known(r.b) then + list[#list + 1] = { id = "f" .. rid, k = "flight", t = r.t, wait = 20, name = nb.n, fa = r.a, fb = r.b, + from = { m = na.m, x = na.x, y = na.y }, to = { m = nb.m, x = nb.x, y = nb.y } } + end + end + edgeCache = { rev = Taxi.rev, key = key, list = list } + return list +end + +function Taxi.ClearRemote() + local db = Codex.db + if db then db.taxir, db.taxin = nil, nil end + Taxi.rev = Taxi.rev + 1 +end + +------------------------------------------------------------------------ +-- Flug erfassen +------------------------------------------------------------------------ + +local function OnTake(slot) + local s = state.slot and state.slot[slot] + if not s or not state.cur then return end + state.pending = { from = state.cur, to = s.id, quote = s.cost or 0, money0 = _G.GetMoney and _G.GetMoney() or 0, + asked = Clock(), started = false } +end + +local function Finish(p, now) + state.pending = nil + Taxi.flight = nil + local T, C = DB(), CharRec(true) + if not T or not C then return end + local dur = now - p.t0 + if dur < 5 then return end + -- Endpunkt: Position der Landung + local P = Codex.Path and Codex.Path.Read and Codex.Path.Read() + if P then + p.pts[#p.pts + 1] = Round(P.y) + p.pts[#p.pts + 1] = Round(P.x) + local n = T.nodes[p.to] + if n and not n.a then n.a, n.b = Round(P.y), Round(P.x) end + if n and not n.m then SetMapPos(n) end + end + local paid = p.paid or 0 + if paid <= 0 or paid > p.quote * 3 + 100 then paid = p.quote or 0 end + local rid = p.from .. ">" .. p.to + local r = T.routes[rid] + local isNew = false + if not r then + local count = 0 + for _ in pairs(T.routes) do count = count + 1 end + if count >= MAX_ROUTES then return end + r = { a = p.from, b = p.to, t = Round(dur), c = paid, n = 1, p = p.pts, upd = Now() } + T.routes[rid] = r + isNew = true + else + r.n = (r.n or 1) + 1 + r.t = Round((r.t * (r.n - 1) + dur) / r.n) + r.c = paid + if #p.pts > #(r.p or {}) then r.p = p.pts end + r.upd = Now() + end + C.known[p.to] = true + C.spent = (C.spent or 0) + paid + C.trips = (C.trips or 0) + 1 + C.secs = (C.secs or 0) + Round(dur) + C.rt = C.rt or {} + local rs = C.rt[rid] + if not rs then rs = { n = 0, sec = 0, gold = 0 }; C.rt[rid] = rs end + rs.n, rs.sec, rs.gold = rs.n + 1, rs.sec + Round(dur), (rs.gold or 0) + paid + C.log = C.log or {} + C.log[#C.log + 1] = { t = Now(), a = p.from, b = p.to, c = paid, d = Round(dur) } + while #C.log > MAX_LOG do table.remove(C.log, 1) end + if isNew then + local na, nb = T.nodes[p.from], T.nodes[p.to] + FW:Print(string.format("Codex: Flugroute %s -> %s gespeichert (%s, %s, %d Punkte)", + na and na.n or p.from, nb and nb.n or p.to, Taxi.Duration(dur), Taxi.Money(paid), math.floor(#p.pts / 2))) + end + Taxi.rev = Taxi.rev + 1 + FW:Fire("CODEX_TAXI_CHANGED") + FW:Fire("CODEX_CHANGED", "taxi") +end + +local function Tick() + local p = state.pending + if not p then return end + local now = Clock() + local on = _G.UnitOnTaxi and _G.UnitOnTaxi("player") and true or false + if on and not p.started then + p.started, p.t0, p.nextAt, p.pts = true, now, now, {} + local r = Taxi.Routes()[p.from .. ">" .. p.to] + local nd = Taxi.Node(p.to) + Taxi.flight = { t0 = now, exp = r and r.t or nil, name = nd and nd.n or "?" } + end + if p.started then + if on then + if now - p.t0 <= PRICE_WINDOW then + local d = p.money0 - (_G.GetMoney and _G.GetMoney() or 0) + if d > (p.paid or 0) then p.paid = d end + end + if now >= p.nextAt and #p.pts < MAX_POINTS * 2 then + local P = Codex.Path and Codex.Path.Read and Codex.Path.Read() + if P then + p.pts[#p.pts + 1] = Round(P.y) + p.pts[#p.pts + 1] = Round(P.x) + end + p.nextAt = now + SAMPLE + end + else + Finish(p, now) + end + elseif now - p.asked > 15 then + state.pending = nil -- Flug nicht angetreten + Taxi.flight = nil + end +end + +local acc = 0 +local frame = _G.CreateFrame and _G.CreateFrame("Frame") +if frame then + frame:SetScript("OnUpdate", function(_, elapsed) + acc = acc + (elapsed or 0) + if acc < 1 then return end + acc = 0 + pcall(Tick) + end) +end + +-- Tooltip am Flugziel auf der Flugkarte: Reisedauer (und wie oft schon geflogen) +function Taxi.ButtonTip(button) + if not (button and button.GetID and state.cur and state.slot) then return end + local s = state.slot[button:GetID()] + local tt = _G.GameTooltip + if not s or not tt or s.id == state.cur then return end + local r = Taxi.Routes()[state.cur .. ">" .. s.id] + local L = FW.L + if r and r.t then + tt:AddLine(string.format(L.CODEX_TAXI_TIP_TIME, Taxi.Duration(r.t)), 0.94, 0.75, 0.36) + else + tt:AddLine(L.CODEX_TAXI_TIP_NONE, 0.6, 0.59, 0.55) + end + local n, sec = Taxi.RouteStat(state.cur, s.id) + if n > 0 then + tt:AddLine(string.format(L.CODEX_TAXI_TIP_COUNT, n, Taxi.Duration(sec)), 0.6, 0.59, 0.55) + end + tt:Show() +end + +local function Install() + if Taxi.hooked then return end + Taxi.hooked = true + if _G.hooksecurefunc and _G.TaxiNodeOnButtonEnter then + pcall(_G.hooksecurefunc, "TaxiNodeOnButtonEnter", function(button) pcall(Taxi.ButtonTip, button) end) + end + if _G.hooksecurefunc and _G.TakeTaxiNode then + pcall(_G.hooksecurefunc, "TakeTaxiNode", function(slot) pcall(OnTake, slot) end) + end + FW:RegisterEvent("TAXIMAP_OPENED", function() pcall(Taxi.Scan) end, Taxi) +end +Install() + +------------------------------------------------------------------------ +-- /fwtaxi +------------------------------------------------------------------------ + +SLASH_FREEWOODTAXI1 = "/fwtaxi" +SlashCmdList["FREEWOODTAXI"] = function(arg) + local T = DB() + if not T then return end + local nodes, routes = 0, 0 + for _ in pairs(T.nodes) do nodes = nodes + 1 end + for _ in pairs(T.routes) do routes = routes + 1 end + if arg == "diag" then + FW:Print(string.format("Taxi: TakeTaxiNode=%s TaxiNodePosition=%s TaxiNodeCost=%s TaxiNodeGetType=%s UnitOnTaxi=%s Hook=%s", + tostring(_G.TakeTaxiNode ~= nil), tostring(_G.TaxiNodePosition ~= nil), tostring(_G.TaxiNodeCost ~= nil), + tostring(_G.TaxiNodeGetType ~= nil), tostring(_G.UnitOnTaxi ~= nil), tostring(Taxi.hooked))) + FW:Print(string.format("Taxi: aktueller Punkt=%s, Plaetze=%d, Flug laeuft=%s", tostring(state.cur), state.slot and #state.slot or 0, + tostring(state.pending and state.pending.started or false))) + return + end + local c = Taxi.CharStats() + FW:Print(string.format("Taxi: %d Flugpunkte, %d Routen gespeichert.", nodes, routes)) + if c then + FW:Print(string.format("Taxi: dieser Charakter kennt %d Flugpunkte, %d Fluege, %s ausgegeben, %s in der Luft.", c.known, c.trips, Taxi.Money(c.spent), Taxi.Duration(c.secs))) + end + local list = {} + for _, r in pairs(T.routes) do list[#list + 1] = r end + table.sort(list, function(x, y) return (x.upd or 0) > (y.upd or 0) end) + for i = 1, math.min(#list, 5) do + local r = list[i] + local na, nb = T.nodes[r.a], T.nodes[r.b] + FW:Print(string.format(" %s -> %s: %s, %s, %d Punkte, %dx", na and na.n or r.a, nb and nb.n or r.b, Taxi.Duration(r.t), Taxi.Money(r.c), + math.floor(#(r.p or {}) / 2), r.n or 1)) + end +end diff --git a/CodexTaxiUI.lua b/CodexTaxiUI.lua new file mode 100644 index 0000000..dce8b0e --- /dev/null +++ b/CodexTaxiUI.lua @@ -0,0 +1,149 @@ +-- Freewood-OctoWoW-Codex: Flugfenster +-- +-- Waehrend eines Fluges mit dem Flugmeister: kleines Fenster mit Zielname, verbleibender Zeit und einem ablaufenden Balken. +-- Ist die Flugdauer der Route noch unbekannt (noch nie geflogen oder empfangen), zaehlt es die vergangene Zeit hoch. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +local Taxi = NS and NS.Taxi +if not FW or not Codex or not Taxi or not FW.UI then + return +end +local UI = FW.UI + +local WIDTH, HEIGHT, BAR_H = 250, 50, 8 +local win + +local function Settings() + local db = Codex.db + if not db then return {} end + db.settings = db.settings or {} + return db.settings +end + +function Taxi.WindowOn() + return Settings().taxiWindow ~= false +end + +local function SavePos() + if not win then return end + local point, _, rel, x, y = win:GetPoint(1) + if point then Settings().taxiWinPos = { point = point, rel = rel, x = x, y = y } end +end + +local function ApplyPos() + win:ClearAllPoints() + local p = Settings().taxiWinPos + if type(p) == "table" and p.point and p.x and p.y then + win:SetPoint(p.point, UIParent, p.rel or p.point, p.x, p.y) + else + win:SetPoint("TOP", UIParent, "TOP", 0, -150) + end +end + +local function Build() + local f = UI.CreatePanel(UIParent, "bg", "gold") + f:SetSize(WIDTH, HEIGHT) + f:SetFrameStrata("MEDIUM") + f:SetClampedToScreen(true) + f:SetMovable(true) + f:EnableMouse(true) + f:RegisterForDrag("LeftButton") + f:SetScript("OnDragStart", function(self) self:StartMoving() end) + f:SetScript("OnDragStop", function(self) self:StopMovingOrSizing(); SavePos() end) + win = f + ApplyPos() + + f.title = UI.CreateLabel(f, "", { size = 12, color = "goldLight" }) + f.title:SetPoint("TOPLEFT", f, "TOPLEFT", 10, -7) + f.title:SetPoint("TOPRIGHT", f, "TOPRIGHT", -10, -7) + f.title:SetHeight(14) + + f.time = UI.CreateLabel(f, "", { size = 11, color = "text" }) + f.time:SetPoint("TOPLEFT", f.title, "BOTTOMLEFT", 0, -2) + f.time:SetPoint("TOPRIGHT", f.title, "BOTTOMRIGHT", 0, -2) + f.time:SetHeight(13) + + f.barBg = f:CreateTexture(nil, "ARTWORK") + f.barBg:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 10, 8) + f.barBg:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -10, 8) + f.barBg:SetHeight(BAR_H) + f.barBg:SetColorTexture(UI.Color("card")) + + f.fill = f:CreateTexture(nil, "OVERLAY") + f.fill:SetPoint("BOTTOMLEFT", f.barBg, "BOTTOMLEFT", 0, 0) + f.fill:SetHeight(BAR_H) + f.fill:SetColorTexture(UI.Color("gold")) + + f.acc = 0 + f:SetScript("OnUpdate", function(self, elapsed) + self.acc = self.acc + (elapsed or 0) + if self.acc < 0.1 then return end + self.acc = 0 + local fl = Taxi.flight + if not fl or not Taxi.WindowOn() then self:Hide() return end + local L = FW.L + local el = (GetTime and GetTime() or 0) - fl.t0 + if self.shownName ~= fl.name then + self.shownName = fl.name + self.title:SetText(string.format(L.CODEX_TAXI_TITLE, fl.name)) + end + local full = self.barBg:GetWidth() or (WIDTH - 20) + if fl.exp and fl.exp > 0 then + local left = fl.exp - el + if left > 0 then + self.time:SetText(string.format(L.CODEX_TAXI_LEFT, Taxi.Duration(math.ceil(left)))) + self.fill:SetColorTexture(UI.Color("gold")) + self.fill:SetWidth(math.max(1, full * left / fl.exp)) + else + self.time:SetText(L.CODEX_TAXI_ARRIVING) + self.fill:SetColorTexture(UI.Color("success")) + self.fill:SetWidth(1) + end + else + self.time:SetText(string.format(L.CODEX_TAXI_UNKNOWN, Taxi.Duration(el))) + self.fill:SetColorTexture(UI.Color("bronze")) + self.fill:SetWidth(math.max(1, full * ((el % 4) / 4))) + end + end) + f:Hide() + return f +end + +-- Das Fenster zeigt sich, sobald ein Flug laeuft +local watch = CreateFrame("Frame") +local acc = 0 +watch:SetScript("OnUpdate", function(_, elapsed) + acc = acc + (elapsed or 0) + if acc < 0.25 then return end + acc = 0 + if Taxi.flight and Taxi.WindowOn() then + if not win then win = Build() end + if not win:IsShown() then win.shownName = nil; win:Show() end + end +end) + +if UI.RegisterSettingsSection then + UI:RegisterSettingsSection("codex-taxi", { + order = 108, + height = 140, + build = function(parent) + local L = FW.L + local f, stack = UI.CreateSection(parent, L.CODEX_TAXI_HEADER) + local note = UI.CreateLabel(f, L.CODEX_TAXI_NOTE, { size = 11, color = "textDim", justifyV = "TOP" }) + stack:Add(note, { fit = true, gap = 8 }) + local cb = UI.CreateCheckbox(f, L.CODEX_TAXI_SWITCH, function() return Taxi.WindowOn() end, + function(on) Settings().taxiWindow = on and true or false end) + stack:Add(cb, { fit = true, gap = 6 }) + local cp = UI.CreateCheckbox(f, L.CODEX_TAXI_PLAN, function() return Taxi.PlannerOn() end, + function(on) Settings().taxiPlan = on and true or false end) + stack:Add(cp, { fit = true, gap = 6 }) + function f.Refresh() cb:Refresh(); cp:Refresh(); stack:Layout() end + stack:Layout() + return f + end, + refresh = function(frame) frame.Refresh() end, + }) +end diff --git a/CodexTime.lua b/CodexTime.lua new file mode 100644 index 0000000..34d0ae6 --- /dev/null +++ b/CodexTime.lua @@ -0,0 +1,496 @@ +-- Freewood-OctoWoW-Codex: Zeitmessung +-- Kampfdauer (Time to kill): wie lange dauert ein Solo-Kampf gegen einen einzelnen Gegner, +-- je Klasse, Spielerstufe und Stufenabstand (Gegner minus Spieler, -5 bis +5). +-- Quest-Zeit: aktive Spielzeit vom ersten Fortschritt bis zur Abgabebereitschaft, bei mehreren +-- gleichzeitig laufenden Quests gleichmaessig aufgeteilt. +-- Zeiten werden als ganze Zehntelsekunden gespeichert. +-- +-- Gespeichert: +-- db.ttk[Klasse][Spielerstufe][Abstand] = { n, sum, min, max } (Klasse zum Beispiel "WARRIOR") +-- units[npcID].tt[Klasse][Spielerstufe] = { n, sum, min, max } (nur lokal) +-- quests[id].tm aufgeteilte aktive Zeit bis fertig, .tr aktive Zeit bis fertig ohne Aufteilen, +-- quests[id].ta aktive Zeit bis zur Abgabe +-- db.qtimer[questID] = laufende Zeitnehmer (ueberleben Neustart des Spiels) + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Util = FW.Util + +local Time = {} +NS.Time = Time +Codex.Time = Time + +local MAX_DELTA = 5 +local MIN_KILL, MAX_KILL = 0.5, 120 -- Sekunden +local KILL_END_WINDOW = 8 -- Kampf muss kurz nach dem Kill enden +local MAX_QUEST_SECONDS = 4 * 3600 +local TICK = 1 + +Time.stats = { fights = 0, ok = 0, skip = {}, quests = 0 } + +local function Now() + return GetTime and GetTime() or time() +end + +local function Safe(fn, ...) + local ok, err = pcall(fn, ...) + if not ok and FW.Debug then + FW:Debug("Codex.Time: " .. tostring(err)) + end + return ok +end + +local function Skip(reason) + Time.stats.skip[reason] = (Time.stats.skip[reason] or 0) + 1 +end + +local function Sample(tbl, key, value) + local s = tbl[key] + if not s then + s = { n = 0, sum = 0 } + tbl[key] = s + end + s.n = s.n + 1 + s.sum = s.sum + value + s.min = s.min and math.min(s.min, value) or value + s.max = s.max and math.max(s.max, value) or value + return s +end + +function Time.PlayerToken() + local token + if UnitClass then + local _, t = UnitClass("player") + token = t + end + if type(token) == "string" and token:match("^%u+$") then + return token + end + return nil +end + +-- Klassenname des Spielers in der Spielsprache (fuer Anzeigen) +function Time.ClassName() + if UnitClass then + local name = UnitClass("player") + if type(name) == "string" and name ~= "" then + return name + end + end + return Time.PlayerToken() or "?" +end + +------------------------------------------------------------------------ +-- Kampfdauer +------------------------------------------------------------------------ + +local fight = nil + +local function InGroup() + return (IsInGroup and IsInGroup()) or (GetNumGroupMembers and GetNumGroupMembers() > 1) or false +end + +local function InInstance() + if IsInInstance then + local inside = IsInInstance() + return inside and true or false + end + return false +end + +function Time:NoteTarget() + if not fight or not fight.ok then + return + end + if not (UnitExists and UnitExists("target")) then + return + end + if UnitCanAttack and not UnitCanAttack("player", "target") then + return + end + local guid = UnitGUID and UnitGUID("target") + if guid == nil or Util.IsSecret(guid) then + fight.ok, fight.why = false, "secret" + return + end + if not fight.targets[guid] then + fight.targets[guid] = true + fight.nTargets = fight.nTargets + 1 + if fight.nTargets > 1 then + fight.ok, fight.why = false, "multi" + return + end + local classification = UnitClassification and UnitClassification("target") + if classification and classification ~= "normal" then + fight.ok, fight.why = false, "elite" + end + local level = UnitLevel and tonumber(UnitLevel("target")) + if level and level > 0 then + fight.tl = level -- Stufe des Gegners, falls der Erfahrungs-Hook sie nicht kennt + end + end +end + +function Time:OnRegenDisabled() + Safe(function() + fight = { start = Now(), targets = {}, nTargets = 0, kills = 0, ok = true } + if InGroup() then + fight.ok, fight.why = false, "group" + elseif InInstance() then + fight.ok, fight.why = false, "instance" + end + Time:NoteTarget() + end) +end + +function Time:OnTargetChanged() + Safe(Time.NoteTarget, Time) +end + +-- vom Erfahrungs-Hook aufgerufen: ein Gegner ist gestorben (pl, ml: Stufen, id: NPC-ID oder nil) +function Time:OnKill(pl, ml, id, classification) + if not fight then + return + end + fight.kills = fight.kills + 1 + fight.killAt = Now() + fight.pl, fight.ml, fight.id = pl, ml or fight.ml, id or fight.id + if classification and classification ~= "normal" and fight.ok then + fight.ok, fight.why = false, "elite" + end +end + +function Time:Record(token, pl, delta, seconds, id) + local ds = math.floor(seconds * 10 + 0.5) + local db = Codex.db + db.ttk = db.ttk or {} + db.ttk[token] = db.ttk[token] or {} + db.ttk[token][pl] = db.ttk[token][pl] or {} + Sample(db.ttk[token][pl], delta, ds) + if id then + local u = Codex:Unit(id, true) + u.tt = u.tt or {} + u.tt[token] = u.tt[token] or {} + Sample(u.tt[token], pl, ds) + end + FW:Fire("CODEX_CHANGED", "ttk", pl) +end + +function Time:OnRegenEnabled() + Safe(function() + local f = fight + fight = nil + if not f then + return + end + local st = Time.stats + st.fights = st.fights + 1 + if not f.ok then + return Skip(f.why or "other") + end + if f.kills ~= 1 then + return Skip(f.kills == 0 and "nokill" or "kills") + end + local seconds = f.killAt - f.start + if seconds < MIN_KILL then + return Skip("short") + end + if seconds > MAX_KILL then + return Skip("long") + end + if Now() - f.killAt > KILL_END_WINDOW then + return Skip("late") + end + local pl = f.pl or (UnitLevel and tonumber(UnitLevel("player"))) + local ml = f.ml or f.tl + local delta = ml and pl and (ml - pl) + if not delta or math.abs(delta) > MAX_DELTA then + return Skip("range") + end + local token = Time.PlayerToken() + if not token then + return Skip("class") + end + st.ok = st.ok + 1 + Time:Record(token, pl, delta, seconds, f.id) + end) +end + +-- Klassenname zu einem Klassenschluessel ("WARRIOR" -> "Krieger"), sonst der Schluessel selbst +function Time.ClassLabel(token) + local names = _G.LOCALIZED_CLASS_NAMES_MALE + if type(names) == "table" and type(names[token]) == "string" then + return names[token] + end + return tostring(token) +end + +-- Kampfdauer einer Kreatur je Klasse (eigene und geteilte Messungen): +-- Liste { cls, secs, n, own } nach Dauer sortiert, Durchschnitt ueber alle Spielerstufen +function Time.UnitClasses(u) + local list = {} + local mine = Time.PlayerToken() + for cls, byPl in pairs(u and u.tt or {}) do + local n, sum = 0, 0 + for _, s in pairs(byPl) do + n = n + (s.n or 0) + sum = sum + (s.sum or 0) + end + if n > 0 then + list[#list + 1] = { cls = cls, secs = sum / n / 10, n = n, own = cls == mine } + end + end + table.sort(list, function(a, b) + if a.secs ~= b.secs then return a.secs < b.secs end + return a.cls < b.cls + end) + return list +end + +-- Durchschnittliche Kampfdauer (Sekunden, Anzahl) einer Kreatur fuer die eigene Klasse +function Time.UnitAverage(u) + local token = Time.PlayerToken() + local byPl = u and u.tt and token and u.tt[token] + if not byPl then + return nil + end + local n, sum = 0, 0 + for _, s in pairs(byPl) do + n = n + (s.n or 0) + sum = sum + (s.sum or 0) + end + if n < 1 then + return nil + end + return sum / n / 10, n +end + +------------------------------------------------------------------------ +-- Quest-Zeit +------------------------------------------------------------------------ + +local lastTick = nil +local ticker = nil + +local function QTimers() + Codex.db.qtimer = Codex.db.qtimer or {} + return Codex.db.qtimer +end + +local function LogEntries() + local list = {} + local count + if C_QuestLog and C_QuestLog.GetNumQuestLogEntries then + count = C_QuestLog.GetNumQuestLogEntries() + elseif GetNumQuestLogEntries then + count = GetNumQuestLogEntries() + end + for i = 1, tonumber(count) or 0 do + local questID, isHeader + if C_QuestLog and C_QuestLog.GetInfo then + local info = C_QuestLog.GetInfo(i) + if type(info) == "table" then + questID, isHeader = info.questID, info.isHeader + end + elseif GetQuestLogTitle then + local _, _, _, h, _, _, _, id = GetQuestLogTitle(i) + questID, isHeader = id, h + end + if type(questID) == "number" and questID > 0 and not isHeader then + list[#list + 1] = questID + end + end + return list +end + +-- Fortschritt (Summe der erfuellten Ziele) und ob alles erledigt ist +local function Progress(questID) + local have, finished, count = 0, true, 0 + if C_QuestLog and C_QuestLog.GetQuestObjectives then + local objs = C_QuestLog.GetQuestObjectives(questID) + if type(objs) == "table" then + for _, o in ipairs(objs) do + if type(o) == "table" then + count = count + 1 + have = have + (tonumber(o.numFulfilled) or 0) + if not o.finished then finished = false end + end + end + end + end + if count == 0 then + finished = false + end + if not finished and C_QuestLog and C_QuestLog.IsComplete then + local ok, done = pcall(C_QuestLog.IsComplete, questID) + if ok and done and count > 0 then finished = true end + end + return have, finished +end + +function Time:ScanQuests() + Safe(function() + local timers = QTimers() + local seen = {} + for _, questID in ipairs(LogEntries()) do + seen[questID] = true + local have, finished = Progress(questID) + local t = timers[questID] + if not t then + -- neu im Log: nur ohne vorhandenen Fortschritt kennen wir den Beginn + t = { eff = 0, raw = 0, all = 0, have = have, started = false, skip = have > 0 or finished } + timers[questID] = t + end + if not t.skip then + if have > (t.have or 0) and not t.started then + t.started = true + end + if finished and t.started and not t.done then + t.done = true + end + end + t.have = have + end + for questID, t in pairs(timers) do + if not seen[questID] then + if t.done then + -- fertige Quests kurz behalten: die Abgabe kann nach dem Verlassen des Logs eintreffen + t.goneAt = t.goneAt or Now() + if Now() - t.goneAt > 60 then timers[questID] = nil end + else + timers[questID] = nil + end + end + end + end) +end + +function Time:Tick() + Safe(function() + local now = Now() + local dt = lastTick and (now - lastTick) or 0 + lastTick = now + if dt <= 0 or dt > 3 then + return + end + if UnitIsAFK and UnitIsAFK("player") then + return + end + local timers = QTimers() + local active = 0 + for _, t in pairs(timers) do + if t.started and not t.done and not t.skip then + active = active + 1 + end + end + for _, t in pairs(timers) do + if t.started and not t.skip then + t.all = (t.all or 0) + dt + if not t.done then + t.eff = t.eff + dt / math.max(1, active) + t.raw = t.raw + dt + end + end + end + end) +end + +-- Quest abgegeben: Zeiten in die Quest schreiben +function Time:OnTurnedIn(questID) + Safe(function() + questID = tonumber(questID) + local t = questID and QTimers()[questID] + if not t or t.skip or not t.started then + if questID then QTimers()[questID] = nil end + return + end + QTimers()[questID] = nil + if not t.done or t.raw < 1 or t.all > MAX_QUEST_SECONDS then + return + end + local q = Codex:Quest(questID, true) + local function Add(field, seconds) + local ds = math.floor(seconds * 10 + 0.5) + q[field] = q[field] or { n = 0, sum = 0 } + local s = q[field] + s.n = s.n + 1 + s.sum = s.sum + ds + s.min = s.min and math.min(s.min, ds) or ds + s.max = s.max and math.max(s.max, ds) or ds + end + Add("tm", t.eff) + Add("tr", t.raw) + Add("ta", t.all) + Time.stats.quests = Time.stats.quests + 1 + FW:Fire("CODEX_CHANGED", "quest", questID) + end) +end + +function Time:OnQuestLog() + if self.scanPending then + return + end + self.scanPending = true + C_Timer.After(0.5, function() + Time.scanPending = nil + Time:ScanQuests() + end) +end + +------------------------------------------------------------------------ +-- Start / Stop +------------------------------------------------------------------------ + +function Time:Start() + FW:RegisterEvent("PLAYER_REGEN_DISABLED", self.OnRegenDisabled, self) + FW:RegisterEvent("PLAYER_REGEN_ENABLED", self.OnRegenEnabled, self) + FW:RegisterEvent("PLAYER_TARGET_CHANGED", self.OnTargetChanged, self) + FW:RegisterEvent("QUEST_LOG_UPDATE", self.OnQuestLog, self) + FW:RegisterEvent("UNIT_QUEST_LOG_CHANGED", self.OnQuestLog, self) + FW:RegisterEvent("QUEST_ACCEPTED", self.OnQuestLog, self) + FW:RegisterEvent("QUEST_TURNED_IN", self.OnTurnedIn, self) + if not ticker and C_Timer and C_Timer.NewTicker then + lastTick = Now() + ticker = C_Timer.NewTicker(TICK, function() Time:Tick() end) + end + C_Timer.After(4, function() Time:ScanQuests() end) +end + +function Time:Stop() + for _, event in ipairs({ "PLAYER_REGEN_DISABLED", "PLAYER_REGEN_ENABLED", "PLAYER_TARGET_CHANGED", + "QUEST_LOG_UPDATE", "UNIT_QUEST_LOG_CHANGED", "QUEST_ACCEPTED", "QUEST_TURNED_IN" }) do + FW:UnregisterEvent(event, self) + end + if ticker and ticker.Cancel then + ticker:Cancel() + end + ticker = nil + fight = nil +end + +function Time:Debug() + local st = self.stats + local n = 0 + for _, byPl in pairs(Codex.db.ttk or {}) do + for _, byDelta in pairs(byPl) do + for _ in pairs(byDelta) do n = n + 1 end + end + end + local reasons = {} + for k, v in pairs(st.skip) do reasons[#reasons + 1] = k .. "=" .. v end + table.sort(reasons) + local active, running = 0, 0 + for _, t in pairs(QTimers()) do + running = running + 1 + if t.started and not t.done and not t.skip then active = active + 1 end + end + FW:Print(string.format("Zeit: Kaempfe %d, gewertet %d, Messreihen %d, uebersprungen: %s", st.fights, st.ok, n, + #reasons > 0 and table.concat(reasons, " ") or "-")) + FW:Print(string.format("Quest-Zeit: Zeitnehmer %d, davon aktiv %d, gespeicherte Abgaben %d", running, active, st.quests)) +end diff --git a/CodexTransport.lua b/CodexTransport.lua new file mode 100644 index 0000000..a357702 --- /dev/null +++ b/CodexTransport.lua @@ -0,0 +1,230 @@ +-- Freewood-OctoWoW-Codex: Verkehrsverbindungen (Schiff, Zeppelin, U-Bahn, Portal) +-- +-- Berechtigte (Modul Freewood-OctoWoW-Dev, Rolle Bearbeiter oder hoeher) erfassen Verbindungen: Start hier, Fahrt, Ziel hier, +-- die Fahrzeit wird gemessen. Gespeichert wird in db.transport[id] und ueber das normale Teilen als Datensatz "g" +-- verschickt; angenommen werden sie nur von Absendern, die selbst berechtigt sind. +-- +-- db.transport[id] = { k = Art, f = "A" | "H" | nil, n = Name, t = Sekunden, a = { m, x, y }, b = { m, x, y }, +-- two = Rueckfahrt moeglich, del = geloescht, upd = Stand } +-- Die ID ist ein Pruefwert aus Art, Fraktion und beiden Orten: derselbe Weg, von zwei Berechtigten erfasst, ergibt denselben Datensatz. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Transport = { draft = {} } +Codex.Transport = Transport +NS.Transport = Transport + +Transport.KINDS = { "boat", "zeppelin", "subway", "portal" } +local KIND_OK = {} +for _, k in ipairs(Transport.KINDS) do KIND_OK[k] = true end + +local function Now() return _G.time and _G.time() or 0 end + +local function DB() + local db = Codex.db + if not db then return nil end + db.transport = db.transport or {} + return db +end + +local function CanEdit() + return Codex.Trust ~= nil and Codex.Trust.CanEdit() +end + +local function Round(v, f) return math.floor(v * f + 0.5) end + +local function Hash(text) + local h = 5381 + for i = 1, #text do h = (h * 33 + text:byte(i)) % 99999989 end + return h +end + +-- Pruefwert (0..99999988) aus Art, Fraktion und den auf etwa 0,5 % gerundeten Orten +function Transport.Id(rec) + local a, b = rec.a or {}, rec.b or {} + return Hash(table.concat({ tostring(rec.k), tostring(rec.f or "-"), tostring(a.m), Round(a.x or 0, 200), Round(a.y or 0, 200), + tostring(b.m), Round(b.x or 0, 200), Round(b.y or 0, 200) }, "|")) +end + +-- Aktueller Ort { m, x, y } oder nil +function Transport.Here() + local loc = FW.Util and FW.Util.GetPlayerLocation and FW.Util.GetPlayerLocation() + if loc and loc.mapID and loc.x and loc.y then + return { m = loc.mapID, x = math.floor(loc.x * 10000 + 0.5) / 10000, y = math.floor(loc.y * 10000 + 0.5) / 10000 } + end + return nil +end + +local function Clock() return _G.GetTime and _G.GetTime() or 0 end + +------------------------------------------------------------------------ +-- Erfassen (Entwurf) +------------------------------------------------------------------------ + +function Transport.Draft() + return Transport.draft +end + +function Transport.Reset() + local d = Transport.draft + d.a, d.b, d.t0, d.t = nil, nil, nil, nil + FW:Fire("CODEX_TRANSPORT_DRAFT") +end + +function Transport.Start() + local here = Transport.Here() + if not here then return false end + local d = Transport.draft + d.a, d.b, d.t = here, nil, nil + d.t0 = Clock() + FW:Fire("CODEX_TRANSPORT_DRAFT") + return true +end + +function Transport.Finish() + local d = Transport.draft + if not d.a or not d.t0 then return false end + local here = Transport.Here() + if not here then return false end + d.b = here + d.t = math.max(1, math.floor(Clock() - d.t0 + 0.5)) + FW:Fire("CODEX_TRANSPORT_DRAFT") + return true +end + +-- Laeuft gerade eine Aufnahme (Start gesetzt, Ziel noch nicht)? Dann zeichnet das Wegenetz nicht mit. +function Transport.Recording() + local d = Transport.draft + return d.a ~= nil and d.b == nil and d.t0 ~= nil +end + +function Transport.SetDraft(key, value) + Transport.draft[key] = value +end + +-- Entwurf pruefen. Rueckgabe: true oder false, Grund +function Transport.CheckDraft() + local d = Transport.draft + if not d.a then return false, "start" end + if not d.b or not d.t then return false, "finish" end + if not KIND_OK[d.kind or "boat"] then return false, "kind" end + if d.a.m == d.b.m and math.abs(d.a.x - d.b.x) < 0.005 and math.abs(d.a.y - d.b.y) < 0.005 then return false, "same" end + return true +end + +------------------------------------------------------------------------ +-- Speichern und Loeschen +------------------------------------------------------------------------ + +local function Touch(id) + if Codex.Share and Codex.Share.InvalidateScan then Codex.Share.InvalidateScan() end + Transport.rev = (Transport.rev or 0) + 1 + FW:Fire("CODEX_TRANSPORT_CHANGED", id) + FW:Fire("CODEX_CHANGED", "transport", id) +end + +local function Log(id, field, old, new) + if Codex.DevLog then + pcall(Codex.DevLog, { who = Codex.Trust and Codex.Trust.SelfKey() or "?", kind = "g", id = id, field = field, old = old, new = new }) + end +end + +local function Describe(rec) + local M = Codex.MapName + return string.format("%s: %s -> %s (%ds)", tostring(rec.k), M and M(rec.a.m) or rec.a.m, M and M(rec.b.m) or rec.b.m, rec.t or 0) +end + +-- Speichert den Entwurf. Rueckgabe: id oder nil, Grund +function Transport.Save() + if not CanEdit() then return nil, "rights" end + local ok, why = Transport.CheckDraft() + if not ok then return nil, why end + local d = Transport.draft + local rec = { + k = d.kind or "boat", f = (d.faction == "A" or d.faction == "H") and d.faction or nil, + n = type(d.name) == "string" and d.name:sub(1, 40) ~= "" and d.name:sub(1, 40) or nil, + t = d.t, a = { m = d.a.m, x = d.a.x, y = d.a.y }, b = { m = d.b.m, x = d.b.x, y = d.b.y }, + two = d.two ~= false, upd = Now(), + } + local db = DB() + local id = Transport.Id(rec) + local old = db.transport[id] + rec.upd = math.max(rec.upd, (old and old.upd or 0) + 1) + db.transport[id] = rec + Log(id, "add", old and not old.del and Describe(old) or nil, Describe(rec)) + Touch(id) + Transport.Reset() + return id +end + +function Transport.Delete(id) + if not CanEdit() then return false end + local db = DB() + local own = db.transport[id] + local rec = own + if not rec then + for _, r in ipairs(Transport.All(true)) do + if r.id == id then rec = r break end + end + end + if not rec then return false end + local del = { k = rec.k, f = rec.f, n = rec.n, t = rec.t, a = rec.a, b = rec.b, two = rec.two, del = true, + upd = math.max(Now(), (rec.upd or 0) + 1) } + db.transport[id] = del + Log(id, "del", Describe(rec), nil) + Touch(id) + return true +end + +------------------------------------------------------------------------ +-- Lesen: eigene und empfangene Verbindungen +------------------------------------------------------------------------ + +-- Liste { id, k, f, n, t, a, b, two, upd }, je ID der neueste Stand; geloeschte nur mit withDeleted +function Transport.All(withDeleted) + local best = {} + local db = DB() + if not db then return {} end + for id, rec in pairs(db.transport) do + if type(rec) == "table" and rec.a and rec.b then best[id] = rec end + end + if Codex.Share and Codex.Share.RemoteRecords then + for _, rec in ipairs(Codex.Share.RemoteRecords("g")) do + local cur = best[rec.id] + if not cur or (rec.upd or 0) > (cur.upd or 0) then best[rec.id] = rec end + end + end + local out = {} + for id, rec in pairs(best) do + if withDeleted or not rec.del then + out[#out + 1] = { id = id, k = rec.k, f = rec.f, n = rec.n, t = rec.t, a = rec.a, b = rec.b, two = rec.two ~= false, upd = rec.upd, del = rec.del } + end + end + table.sort(out, function(x, y) + if x.k ~= y.k then return x.k < y.k end + return x.id < y.id + end) + return out +end + +-- Fahrten als gerichtete Kanten { id, k, t, from = { m, x, y }, to = { m, x, y } }; faction = "A" | "H" | nil (alle) +function Transport.Edges(faction) + local out = {} + for _, r in ipairs(Transport.All()) do + if not (faction and r.f and r.f ~= faction) then + out[#out + 1] = { id = r.id, k = r.k, t = r.t, from = r.a, to = r.b, name = r.n } + if r.two then out[#out + 1] = { id = r.id, k = r.k, t = r.t, from = r.b, to = r.a, name = r.n } end + end + end + return out +end + +function Transport.Count() + return #Transport.All() +end diff --git a/CodexTrust.lua b/CodexTrust.lua new file mode 100644 index 0000000..e8c73cf --- /dev/null +++ b/CodexTrust.lua @@ -0,0 +1,308 @@ +-- Freewood-OctoWoW-Codex: Berechtigungen (wer darf Daten fuer alle bearbeiten?) +-- +-- Rollen: +-- owner steht in CodexOwners.lua, darf die Liste veroeffentlichen +-- moderator darf Namen aendern, Datensaetze als fehlerhaft markieren und fremde Markierungen aufheben +-- editor darf Namen aendern und Datensaetze als fehlerhaft markieren +-- blocked gesperrt: nichts wird mehr von diesem Charakter uebernommen +-- +-- Die Liste (Charakter -> Rolle) kommt von einem Eigentuemer ueber den Kanal (Nachricht "T"). Der Absender einer +-- Addon-Nachricht wird vom Spiel gesetzt; Listen anderer Absender werden ignoriert. +-- Battle.net-Namen und Notizen bleiben beim Eigentuemer (Modul Freewood-OctoWoW-Rechte) und werden nie verschickt. +-- Bearbeiten (Namen, Markierungen) geht nur, wenn zusaetzlich das Modul Freewood-OctoWoW-Dev vorhanden ist. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Trust = { buffers = {} } +NS.Trust = Trust +Codex.Trust = Trust + +local MAX_ENTRIES = 150 +local ROLE_CODE = { editor = "e", moderator = "m", blocked = "b" } +local CODE_ROLE = { e = "editor", m = "moderator", b = "blocked" } +Trust.ROLES = { "editor", "moderator", "blocked" } + +local ACTIONS = { + editor = { name = true, bad = true, export = true }, + moderator = { name = true, bad = true, unbad = true, export = true }, +} + +------------------------------------------------------------------------ +-- Namen +------------------------------------------------------------------------ + +local function Core(name) + if type(name) ~= "string" then return "" end + local n = name:gsub("\194\160", " "):match("^([^%-]*)") or "" + return (n:gsub("%s", ""):lower()) +end + +local function RealmPart(name) + local r = type(name) == "string" and name:match("%-(.+)$") + if r then + r = r:gsub("[%s%-]", ""):lower() + if r ~= "" then return r end + end + return nil +end + +local function OwnRealm() + local r = (GetNormalizedRealmName and GetNormalizedRealmName()) or (GetRealmName and GetRealmName()) or "" + return (tostring(r):gsub("[%s%-]", ""):lower()) +end + +-- Der eigene Realm hat im Spiel mehrere Schreibweisen (z.B. GetNormalizedRealmName = "ClassicBetaPvP2", +-- UnitFullName liefert "Steel"). Alle gelten als derselbe Realm und werden auf OwnRealm() vereinheitlicht. +local function IsOwnRealmVariant(r) + if r == OwnRealm() then return true end + local variants = {} + if GetRealmName then variants[#variants + 1] = GetRealmName() end + if UnitFullName then + local ok, _, ur = pcall(UnitFullName, "player") + if ok then variants[#variants + 1] = ur end + end + for _, v in ipairs(variants) do + if type(v) == "string" and v:gsub("[%s%-]", ""):lower() == r then return true end + end + return false +end + +-- "name-realm" in Kleinschreibung; ohne Realm gilt der eigene +function Trust.Key(name) + local core = Core(name) + if core == "" then return nil end + local realm = RealmPart(name) + if not realm or IsOwnRealmVariant(realm) then realm = OwnRealm() end + return core .. "-" .. realm +end + +-- Eintrag aus einer Liste oder von Hand getippt ("Speedtest-Timerun", "Aroki Steel", "Yuto-ClassicBetaPvP2"). +-- In Forever haben Charaktere Vor- und Nachnamen ("Aroki Steel"); ein Bindestrich gilt nur dann als Realm-Trenner, +-- wenn dahinter der eigene Realm steht. Sonst ist es der Nachname (Bindestrich = Leerzeichen). +function Trust.EntryKey(name) + if type(name) ~= "string" then return nil end + local clean = name:gsub("\194\160", " "):gsub("^%s+", ""):gsub("%s+$", "") + local base, realm = clean:match("^(.*)%-([^%-]+)$") + if base and realm:gsub("[%s%-]", ""):lower() == OwnRealm() then + return Trust.Key(clean) + end + return Trust.Key((clean:gsub("%-", " "))) +end + +-- Eigener voller Name. UnitFullName liefert in Forever Vorname und Nachname ("Aroki", "Steel"); die anderen +-- sehen uns als "Aroki Steel-ClassicBetaPvP2". Weicht der zweite Teil vom eigenen Realm ab, ist es der Nachname. +function Trust.SelfKey() + local name, second + if UnitFullName then + local ok, n, r = pcall(UnitFullName, "player") + if ok then name, second = n, r end + end + if not name and UnitName then name = (UnitName("player")) end + if not name then return nil end + if second and second ~= "" and second:gsub("[%s%-]", ""):lower() ~= OwnRealm() then + name = name .. " " .. second + end + return Trust.Key(name) +end + +local function Data() + local db = Codex.db + if not db then return { rev = 0, list = {} } end + db.trust = db.trust or { rev = 0, list = {} } + db.trust.list = db.trust.list or {} + return db.trust +end + +local function OwnerKeys() + local set = {} + for _, n in ipairs(NS.Owners or {}) do + local k = Trust.EntryKey(n) + if k then set[k] = true end + end + return set +end + +------------------------------------------------------------------------ +-- Abfragen +------------------------------------------------------------------------ + +function Trust.IsOwner(name) + local k = Trust.Key(name) + return k ~= nil and OwnerKeys()[k] == true +end + +function Trust.IsSelfOwner() + return Trust.IsOwner(Trust.SelfKey()) +end + +-- Rolle: "owner", "moderator", "editor", "blocked" oder nil +function Trust.RoleOf(name) + local k = Trust.Key(name) + if not k then return nil end + local entry = Data().list[k] + if entry and entry.role == "blocked" then return "blocked" end + if OwnerKeys()[k] then return "owner" end + return entry and entry.role or nil +end + +function Trust.IsDev(name) + local r = Trust.RoleOf(name) + return r == "owner" or r == "moderator" or r == "editor" +end + +function Trust.SelfRole() + -- OctoWoW-Test: Bypass der Berechtigungsliste (FW.OCTO.testBypass in Freewood-OctoWoW-Core/Core/Init.lua) + if FW.OCTO and FW.OCTO.testBypass then return "owner" end + return Trust.RoleOf(Trust.SelfKey()) +end + +-- Darf dieser Charakter eine Aktion ausfuehren? Aktion: name, bad, unbad, export. Braucht das Modul Freewood-OctoWoW-Dev. +function Trust.Can(action) + if not Codex.DevModule then return false end + if FW.OCTO and FW.OCTO.testBypass then return true end -- OctoWoW-Test + local role = Trust.SelfRole() + if role == "owner" then return true end + local a = role and ACTIONS[role] + return a ~= nil and a[action] == true +end + +function Trust.CanEdit() + return Trust.Can("name") +end + +function Trust.Revision() + return Data().rev or 0 +end + +function Trust.Entries() + local out = {} + for key, e in pairs(Data().list) do + out[#out + 1] = { key = key, role = e.role } + end + table.sort(out, function(a, b) return a.key < b.key end) + return out +end + +------------------------------------------------------------------------ +-- Liste setzen und verschicken +------------------------------------------------------------------------ + +local function Changed() + if Codex.Share and Codex.Share.TrustChanged then Codex.Share.TrustChanged() end + FW:Fire("CODEX_TRUST_CHANGED") + FW:Fire("CODEX_CHANGED", "trust") +end + +-- entries: Liste { key, role } +function Trust.SetList(entries, rev) + local d = Data() + local list = {} + local n = 0 + for _, e in ipairs(entries or {}) do + local key = Trust.Key(e.key) + if key and ROLE_CODE[e.role] and n < MAX_ENTRIES then + list[key] = { role = e.role } + n = n + 1 + end + end + d.list = list + d.rev = rev or math.max((d.rev or 0) + 1, time and time() or 1) -- Zeitstempel: bleibt auch nach einem Zuruecksetzen steigend + d.stamp = time and time() or 0 + Changed() + return d.rev +end + +-- Nachrichten "T|rev|i/n|text" (Eintraege "name-realm,e;name-realm,m;...") +function Trust.Chunks() + local d = Data() + local parts = {} + for _, e in ipairs(Trust.Entries()) do + local key = e.key:gsub("[;,|]", "") + parts[#parts + 1] = key .. "," .. ROLE_CODE[e.role] + end + local chunks, cur = {}, "" + for _, p in ipairs(parts) do + if #cur + #p + 1 > 190 then + chunks[#chunks + 1] = cur + cur = "" + end + cur = cur .. (cur ~= "" and ";" or "") .. p + end + chunks[#chunks + 1] = cur + local out = {} + for i, c in ipairs(chunks) do + out[i] = string.format("T|%d|%d/%d|%s", d.rev or 0, i, #chunks, c) + end + return out +end + +local function Parse(payload) + local list = {} + for item in (payload .. ";"):gmatch("([^;]*);") do + local key, code = item:match("^(.-),([emb])$") + if key and key ~= "" then + list[#list + 1] = { key = key, role = CODE_ROLE[code] } + end + end + return list +end + +-- Empfang: nur von Eigentuemern. body = "rev|i/n|text". Rueckgabe: true, wenn eine neue Liste uebernommen wurde +function Trust.Receive(sender, body) + if not Trust.IsOwner(sender) then return false end + local rev, i, n, payload = tostring(body):match("^(%d+)|(%d+)/(%d+)|(.*)$") + rev, i, n = tonumber(rev), tonumber(i), tonumber(n) + if not rev or not i or not n or n < 1 or n > 12 or i < 1 or i > n then return false end + local d = Data() + if rev <= (d.rev or 0) then return false end -- aelter oder schon vorhanden + local key = Trust.Key(sender) + local buf = Trust.buffers[key] + if not buf or buf.rev ~= rev or buf.n ~= n then + buf = { rev = rev, n = n, parts = {}, got = 0 } + Trust.buffers[key] = buf + end + if not buf.parts[i] then + buf.parts[i] = payload + buf.got = buf.got + 1 + end + if buf.got < n then return false end + Trust.buffers[key] = nil + local all = {} + for idx = 1, n do all[#all + 1] = buf.parts[idx] end + Trust.SetList(Parse(table.concat(all, ";")), rev) + return true +end + +------------------------------------------------------------------------ +-- Diagnose: /fwrolle +------------------------------------------------------------------------ + +SLASH_FREEWOODROLE1 = "/fwrolle" +SlashCmdList["FREEWOODROLE"] = function() + local d = Data() + local n = 0 + for _ in pairs(d.list) do n = n + 1 end + local L = FW.L + FW:Print(string.format(L.CODEX_ROLE_INFO, tostring(Trust.SelfKey()), tostring(Trust.SelfRole() or "-"), + Codex.DevModule and L.CODEX_YES or L.CODEX_NO, n, d.rev or 0)) + -- Ausfuehrliche Diagnose: was liefert das Spiel, und welche Schluessel stehen in der Eigentuemer-Liste? + local ok, un, ur = false, nil, nil + if UnitFullName then ok, un, ur = pcall(UnitFullName, "player") end + FW:Print(string.format("Rolle: UnitFullName = %s | %s, UnitName = %s, GetRealmName = %s, GetNormalizedRealmName = %s, eigener Realm = %s", + tostring(un), tostring(ur), tostring(UnitName and UnitName("player")), tostring(GetRealmName and GetRealmName()), + tostring(GetNormalizedRealmName and GetNormalizedRealmName()), OwnRealm())) + local self = Trust.SelfKey() + local found = false + for _, entry in ipairs(NS.Owners or {}) do + local k = Trust.EntryKey(entry) + if k == self then found = true end + FW:Print(string.format("Rolle: Eigentuemer \"%s\" -> %s%s", tostring(entry), tostring(k), k == self and " <== du" or "")) + end + FW:Print(string.format("Rolle: dein Schluessel %s steht %s in der Eigentuemer-Liste", tostring(self), found and "" or "NICHT")) +end diff --git a/CodexUI.lua b/CodexUI.lua new file mode 100644 index 0000000..2c40995 --- /dev/null +++ b/CodexUI.lua @@ -0,0 +1,4415 @@ +-- Freewood-OctoWoW-Codex: Tab "Datenbank" +-- Oben die Arten (Quests, Kreaturen, Objekte, Items), links Suche und Liste, rechts das Detail. +-- Das Detail ist eine Folge von Zeilen; Zeilen mit Verweis fuehren zum passenden Eintrag. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS.Codex +if not FW or not Codex then + return +end + +local UI = FW.UI +local Util = FW.Util + +local LIST_W = 540 +local ROW_H, ROW_GAP = 44, 4 +local DEFAULT_ROWS = 300 +local function MaxRows() + local n = Codex.db and Codex.db.settings and tonumber(Codex.db.settings.listMax) + if not n or n < 50 then return DEFAULT_ROWS end + return math.floor(n) +end +local LINE_H = 24 + +local KINDS = { + { id = "quests", label = "CODEX_KIND_QUESTS" }, + { id = "units", label = "CODEX_KIND_UNITS" }, + { id = "objects", label = "CODEX_KIND_OBJECTS" }, + { id = "items", label = "CODEX_KIND_ITEMS" }, + { id = "zones", label = "CODEX_KIND_ZONES" }, + { id = "xp", label = "CODEX_KIND_XP" }, + { id = "learn", label = "CODEX_KIND_LEARN" }, +} + +local SORTS = { + quests = { "name", "zone", "recent", "lvl", "xp", "money", "qtime", "dist" }, + units = { "name", "recent", "gold", "ttk", "gpm", "ahkill", "gpmah", "lvl", "xpm", "data", "dist" }, + objects = { "name", "recent", "gold", "data", "dist" }, + items = { "name", "price", "buy", "ah", "flip", "ilvl", "quality", "chance", "gph" }, + zones = { "name", "zlvl", "cnt", "recent" }, + xp = { "name", "recent", "xpm" }, + learn = { "step" }, +} +-- Standardrichtung: true = aufsteigend (kleinster Wert zuerst) +local SORT_ASC = { zlvl = true, zone = true, step = true, name = true, ttk = true, qtime = true, buy = true, dist = true, data = true } +-- Anzeige des Sortierwerts in der Zeile: "money" (Kupfer), "secs", "num" +local SORT_FORMAT = { + gold = "money", gpm = "money", ahkill = "money", gpmah = "money", price = "money", buy = "money", ah = "money", flip = "money", gph = "money", money = "money", + ttk = "secs", qtime = "secs", lvl = "num", xp = "num", xpm = "num", data = "num", cnt = "num", +} +local SORT_METRIC = { + step = "step", price = "price", ilvl = "ilvl", quality = "quality", chance = "chance", gold = "gold", ttk = "ttk", gpm = "gpm", ahkill = "ahkill", gpmah = "gpmah", + lvl = "lvl", xpm = "xpm", xp = "xp", money = "money", qtime = "qtime", buy = "buy", ah = "ah", flip = "flip", gph = "gph", data = "data", + zlvl = "zlvl", cnt = "cnt", +} + +local XPTitle, XPKey +local ICON = { + quests = "Interface\\GossipFrame\\AvailableQuestIcon", + units = "Interface\\Icons\\INV_Misc_Bone_HumanSkull_01", + objects = "Interface\\Icons\\INV_Misc_Bag_10", + items = "Interface\\Icons\\INV_Misc_QuestionMark", + zones = "Interface\\Icons\\INV_Misc_Map_01", + xp = "Interface\\Icons\\INV_Misc_Book_09", + learn = "Interface\\Icons\\INV_Misc_Note_01", + plus = "Interface\\Buttons\\UI-PlusButton-Up", + minus = "Interface\\Buttons\\UI-MinusButton-Up", +} + +-- Kreaturentypen, deren Modelle in ClassicAPI keinen Ankleide-Auftrag brauchen: +-- 1 Wildtier, 2 Drachkin, 3 Daemon, 4 Elementar, 5 Riese, 8 Kleintier, 9 Mechanisch +local SAFE_MODEL_TYPE = { [1] = true, [2] = true, [3] = true, [4] = true, [5] = true, [8] = true, [9] = true } + +-- Reihenfolge = Vorrang +local ROLE_ICONS = { + { "classtrainer", "Interface\\Icons\\INV_Misc_Book_09" }, + { "proftrainer", "Interface\\Icons\\INV_Misc_Book_06" }, + { "flight", "Interface\\Icons\\INV_Misc_Map_01" }, + { "banker", "Interface\\Icons\\INV_Misc_Coin_02" }, + { "auctioneer", "Interface\\Icons\\INV_Misc_Coin_01" }, + { "innkeeper", "Interface\\Icons\\INV_Misc_Key_03" }, + { "stablemaster", "Interface\\Icons\\Ability_Hunter_BeastTaming" }, + { "battlemaster", "Interface\\Icons\\INV_BannerPVP_01" }, + { "spirithealer", "Interface\\Icons\\Spell_Holy_GuardianSpirit" }, + { "repair", "Interface\\Icons\\Trade_BlackSmithing" }, + { "vendor", "Interface\\Icons\\INV_Misc_Bag_08" }, +} + +-- Symbole je Kreaturtyp (Spiel-Symbole; fehlt eines im Client, erscheint ein leeres Feld: dann hier tauschen) +local CT_ICONS = { + beast = "Interface\\Icons\\Ability_Hunter_BeastCall", + humanoid = "Interface\\Icons\\INV_Misc_Head_Human_01", + undead = "Interface\\Icons\\Spell_Shadow_RaiseDead", + demon = "Interface\\Icons\\Spell_Shadow_SummonImp", + dragonkin = "Interface\\Icons\\INV_Misc_Head_Dragon_01", + elemental = "Interface\\Icons\\Spell_Fire_Elemental_Totem", + giant = "Interface\\Icons\\Spell_Nature_StrengthOfEarthTotem", + mechanical = "Interface\\Icons\\INV_Gizmo_01", + critter = "Interface\\Icons\\INV_Misc_Rabbit_2", + totem = "Interface\\Icons\\Spell_Nature_ManaRegenTotem", + gascloud = "Interface\\Icons\\Spell_Nature_Cyclone", + other = "Interface\\Icons\\INV_Misc_QuestionMark", + pet = "Interface\\Icons\\INV_Box_PetCarrier_01", +} + +-- Symbol einer Kreatur: Familien-Symbol (wenn das Pet-Addon die Familie kennt), sonst Totenkopf +local function UnitIcon(u) + local Pet = FW.Pet + if u and u.f and Pet and Pet.GetFamilyID and Pet.FamilyIcon and Pet.GetFamilyID(u.f) then + return Pet.FamilyIcon(u.f) + end + -- Rolle (Haendler, Lehrer, Bank ...) als Symbol; ein 2D-Portrait gibt es fuer reine IDs nicht + local ro = u and u.ro + if type(ro) == "table" then + for _, pair in ipairs(ROLE_ICONS) do + if (ro[pair[1]] or 0) > 0 then + return pair[2] + end + end + end + -- Kreaturtyp (Wildtier, Untoter, ...) als Symbol + if u and u.ct and CT_ICONS[u.ct] then + return CT_ICONS[u.ct] + end + return ICON.units +end + +------------------------------------------------------------------------ +-- Formate +------------------------------------------------------------------------ + +local function FormatMoney(copper) + copper = math.floor(tonumber(copper) or 0) + local g = math.floor(copper / 10000) + local s = math.floor((copper % 10000) / 100) + local c = copper % 100 + local parts = {} + if g > 0 then parts[#parts + 1] = "|cffffd100" .. g .. "g|r" end + if s > 0 or g > 0 then parts[#parts + 1] = "|cffc7c7cf" .. s .. "s|r" end + parts[#parts + 1] = "|cffeda55f" .. c .. "c|r" + return table.concat(parts, " ") +end + +Codex.FormatMoney = FormatMoney + +-- Preise eines Artikels beim Haendler: hoechster Preis = 100 %, billigere mit Prozent (Ruf, Talente) +local function PriceText(prices, ec) + local list = {} + for price, n in pairs(prices or {}) do list[#list + 1] = { price = price, n = n } end + if #list == 0 then return nil end + table.sort(list, function(a, b) return a.price > b.price end) + local top = list[1].price + local parts = {} + for i, e in ipairs(list) do + if i > 3 then break end + local text = e.price > 0 and FormatMoney(e.price) or "-" + if i > 1 and top > 0 then + text = text .. string.format(" (%d %%)", math.floor(e.price / top * 100 + 0.5)) + end + parts[#parts + 1] = text + end + local out = table.concat(parts, " ") + if ec then out = out .. " +" .. FW.L.CODEX_WARE_TOKEN end + return out +end + +local function FormatDuration(seconds) + seconds = math.floor((tonumber(seconds) or 0) + 0.5) + if seconds >= 3600 then + return string.format("%d:%02d:%02d", math.floor(seconds / 3600), math.floor((seconds % 3600) / 60), seconds % 60) + end + return string.format("%d:%02d", math.floor(seconds / 60), seconds % 60) +end + +-- Kampfdauer fuer die eigene Klasse: Sekunden, Anzahl (Erfahrung-Zeilen und Details) +local function OwnTTK(pl, ml) + local token = Codex.Time and Codex.Time.PlayerToken() + if not (token and Codex.Share and Codex.Share.TTKMeasured) then + return nil + end + return Codex.Share.TTKMeasured(token, pl, ml - pl) +end + +local function FormatChance(chance) + local p = chance * 100 + if p >= 10 then + return string.format("%.0f%%", p) + end + return string.format("%.1f%%", p) +end + +local function Colored(text, quality) + return "|cff" .. UI.RarityHex(quality or 1) .. text .. "|r" +end + +local function SeedOK() + return Codex.Seed ~= nil and Codex.Seed.Available() +end + +-- Angaben des Servers zu einer Kreatur / einem Objekt ueber die ID (ClassicAPI: C_CreatureInfo, C_GameObjectInfo). +-- request = true laedt Unbekanntes einmal nach und zeichnet die Detailseite danach neu (nie in Listen: sonst Anfrageflut) +local srvAsked, srvRedraw = {}, false +local function ServerInfo(kind, id, request) + if type(id) ~= "number" then return nil end + local info, asked + local C = kind == "units" and _G.C_CreatureInfo or _G.C_GameObjectInfo + local get = C and (kind == "units" and C.GetCreatureInfoByID or C.GetGameObjectInfoByID) + if get then + local ok, r = pcall(get, id) + if ok and type(r) == "table" then info = r end + end + if not info and Codex.Catalog and Codex.Catalog.GetEntity then + local nm, sb, f = Codex.Catalog.GetEntity(kind == "units" and "c" or "o", id) + if nm then + info = { name = nm, subName = sb, type = f and f.type, rank = f and f.rank, family = f and f.family, fromCatalog = true } + end + end + if not info and request and C then + local key = (kind == "units" and "u" or "o") .. id + local req = kind == "units" and C.RequestLoadCreatureByID or C.RequestLoadGameObjectByID + if req and not srvAsked[key] then + srvAsked[key] = true + asked = true + pcall(req, id) + end + end + if asked and not srvRedraw and _G.C_Timer then + srvRedraw = true + _G.C_Timer.After(1.5, function() + srvRedraw = false + if Codex.RedrawDetail then Codex.RedrawDetail() end + end) + end + return info +end + +-- Spielobjekt-Typen (GameObjectType): englisch, deutsch +local GO_TYPES = { + [0] = { "Door", "Tür" }, [1] = { "Button", "Schalter" }, [2] = { "Quest giver", "Questgeber" }, [3] = { "Chest", "Truhe" }, + [5] = { "Generic", "Allgemein" }, [6] = { "Trap", "Falle" }, [7] = { "Chair", "Stuhl" }, [8] = { "Spell focus", "Zauberfokus" }, + [9] = { "Text", "Text" }, [10] = { "Interactive object", "Interaktives Objekt" }, [11] = { "Transport", "Transport" }, + [17] = { "Fishing node", "Angelstelle" }, [19] = { "Mailbox", "Briefkasten" }, [22] = { "Spell caster", "Zauberauslöser" }, + [25] = { "Fishing hole", "Fischschwarm" }, +} + +-- Name einer Kreatur / eines Objekts: eigener Eintrag, sonst die Vorlage (nur wenn die Art bekannt ist), sonst der Server +local function UnitTitle(u, id, kind) + local name = Codex.NameOf(u) + if not name and kind and SeedOK() then + name = Codex.Seed.Name(kind, id) + end + if not name and kind then + local si = ServerInfo(kind, id, false) + if si and type(si.name) == "string" and si.name ~= "" then name = si.name end + end + return name or ("#" .. tostring(id)) +end + +local function SeedTag() + return "|cff8a8a96[" .. FW.L.CODEX_SEED_TAG .. "]|r" +end + +local function SeedPct(chance) + if not chance or chance <= 0 then return "" end -- Chance unbekannt + if chance >= 10 then + return string.format(FW.L.CODEX_SEED_CHANCE, string.format("%.0f", chance)) + end + if chance < 1 then + return string.format(FW.L.CODEX_SEED_CHANCE, string.format("%.2f", chance)) + end + return string.format(FW.L.CODEX_SEED_CHANCE, string.format("%.1f", chance)) +end + +local function RoleNames(kind, id) + local list = {} + for _, role in ipairs(Codex.RoleList(kind, id)) do + list[#list + 1] = FW.L["CODEX_ROLE_" .. role] or role + end + return list +end + + +local function LevelText(u) + local L = FW.L + if u.l1 and u.l2 then + if u.l1 == u.l2 then + return string.format(L.CODEX_LEVEL, u.l1) + end + return string.format(L.CODEX_LEVEL_RANGE, u.l1, u.l2) + end + return nil +end + +------------------------------------------------------------------------ +-- Verzeichnisse (Rueckwaerts-Suche), bei Aenderung neu aufgebaut +------------------------------------------------------------------------ + +local dirty = true +local itemIndex, itemIds + +-- Rueckgabe: index[itemID] = { u, o, r, qi, zones = { [mapID] = true }, best = hoechste Drop-Chance }, Liste der IDs +local function ItemIndex() + if itemIndex and not dirty then + return itemIndex, itemIds + end + local index, ids = {}, {} + local grid2 = Codex.GRID * Codex.GRID + local function Facts(id) + local f = index[id] + if not f then + f = { zones = {}, best = 0 } + index[id] = f + ids[#ids + 1] = id + end + return f + end + local function FromEntities(tbl, flag) + for _, ent in pairs(tbl) do + local maps = {} + for key in pairs(ent.cells or {}) do + maps[math.floor(key / grid2)] = true + end + for _, m in ipairs(ent.ez or {}) do maps[m] = true end + for iid in pairs(ent.iz or {}) do maps[-iid] = true end + for id in pairs(ent.wa or {}) do + local f = Facts(id) + f.v = true + for m in pairs(maps) do f.zones[m] = true end + end + for bi, bucket in ipairs({ ent, ent.alt or {} }) do + for id, d in pairs(bucket.d or {}) do + local f = Facts(id) + f[flag] = true + if d.qi then f.qi = true end + if bi == 1 and (bucket.k or 0) > 0 and d.n then + local chance = d.n / bucket.k + if chance > f.best then f.best = chance end + end + for m in pairs(maps) do f.zones[m] = true end + end + end + end + end + FromEntities(Codex.V().units, "u") + FromEntities(Codex.V().objects, "o") + -- im Auktionshaus gescannte Items (nicht in ids: sonst Flut von Item-Anfragen) + if Codex.ExtraItems then + local ok, extra = pcall(Codex.ExtraItems) + if ok and type(extra) == "table" then + for id in pairs(extra) do + if not index[id] then index[id] = { zones = {}, best = 0, ah = true } end + end + end + end + -- Items aus den Startdaten, deren Itemdaten schon geladen sind (sonst nur Zahlen ohne Namen) + if Codex.Forever and Codex.Forever.ItemIds then + for id in pairs(Codex.Forever.ItemIds()) do + if not index[id] and Util.GetItem(id) then + local it = Codex.Forever.Item(id) + local f = { zones = {}, best = 0, fe = true } + if it then + f.u = (#it.u > 0) or nil + f.o = (#it.o > 0) or nil + f.v = (#it.v > 0) or nil + for _, e in ipairs(it.u) do if e.chance / 100 > f.best then f.best = e.chance / 100 end end + end + index[id] = f + end + end + end + for _, q in pairs(Codex.V().quests) do + for _, list in ipairs({ q.fixed or {}, q.choice or {} }) do + for _, r in ipairs(list) do + local f = Facts(r.id) + f.r = true + if q.zone then f.zones[q.zone] = true end + end + end + for id in pairs(q.qi or {}) do + local f = Facts(id) + f.qi = true + if q.zone then f.zones[q.zone] = true end + end + end + -- von Hand gesetzte Zonen der Items (nur lokal) + for id, rec in pairs(Codex.V().ied or Codex.db.ied or {}) do + local z = rec.ez or (rec.ed and rec.ed.z) + if z and index[id] then + for _, m in ipairs(z) do index[id].zones[m] = true end + end + end + itemIndex, itemIds = index, ids + dirty = false + return index, ids +end + +function Codex.AllItemIds() + local _, ids = ItemIndex() + local list, seen = {}, {} + for _, id in ipairs(ids) do seen[id] = true list[#list + 1] = id end + if Codex.Forever and Codex.Forever.ItemIds then + for id in pairs(Codex.Forever.ItemIds()) do + if not seen[id] then seen[id] = true list[#list + 1] = id end + end + end + for id in pairs(Codex.db.cat and Codex.db.cat.i or {}) do + if type(id) == "number" and not seen[id] then seen[id] = true list[#list + 1] = id end + end + return list +end +function Codex.MarkItemsDirty() dirty = true end + +-- Wert-Filter: flt.stats = { { key, min, max }, ... } (bis zu 3 Zeilen), flt.ops[i] = "and" | "or" zwischen +-- Zeile i und der naechsten. UND bindet staerker als ODER. Aeltere Form: flt.stat / statMin / statMax (eine Zeile). +local MAX_STAT_ROWS = 3 + +local function StatRows(flt) + local rows = {} + if flt.stats then + for i = 1, MAX_STAT_ROWS do + local r = flt.stats[i] + if r and r.key then + rows[#rows + 1] = { key = r.key, min = r.min, max = r.max, op = flt.ops and flt.ops[i] or "and" } + end + end + elseif flt.stat then + rows[1] = { key = flt.stat, min = flt.statMin, max = flt.statMax, op = "and" } + end + return rows +end + +local function StatRowPass(st, r) + local v = st[r.key] or 0 + if r.min then + if v < r.min then return false end + elseif v <= 0 then + return false + end + if r.max and v > r.max then return false end + return true +end + +-- Gruppen: ODER trennt, UND verbindet. Das Operatorfeld einer Zeile gilt zur naechsten aktiven Zeile. +local function StatGroups(rows) + local groups = {} + for i, r in ipairs(rows) do + if i == 1 or rows[i - 1].op == "or" then + groups[#groups + 1] = { r } + else + local g = groups[#groups] + g[#g + 1] = r + end + end + return groups +end + +local function StatsPass(st, flt) + local rows = StatRows(flt) + if #rows == 0 then return true end + if not st then return false end + for _, g in ipairs(StatGroups(rows)) do + local all = true + for _, r in ipairs(g) do + if not StatRowPass(st, r) then all = false break end + end + if all then return true end + end + return false +end + +local function StatRowText(r) + local name = Codex.StatName(r.key) + if r.min and r.max then + return string.format("%s %s-%s", name, Codex.FormatStat(r.min), Codex.FormatStat(r.max)) + elseif r.min then + return string.format("%s >= %s", name, Codex.FormatStat(r.min)) + elseif r.max then + return string.format("%s <= %s", name, Codex.FormatStat(r.max)) + end + return name +end + +-- Klartext des Wert-Filters, zum Beispiel "(Beweglichkeit 8-10 UND Intelligenz 5-7) ODER Schwimmtempo" +local function StatPreview(flt) + local rows = StatRows(flt) + if #rows < 2 then return "" end + local L = FW.L + local groups = StatGroups(rows) + local parts = {} + for _, g in ipairs(groups) do + local texts = {} + for _, r in ipairs(g) do texts[#texts + 1] = StatRowText(r) end + local text = table.concat(texts, " " .. L.CODEX_OP_AND .. " ") + if #g > 1 and #groups > 1 then text = "(" .. text .. ")" end + parts[#parts + 1] = text + end + return table.concat(parts, " " .. L.CODEX_OP_OR .. " ") +end + +-- Filter fuer Items. Filter, die Itemdaten brauchen, schliessen noch nicht geladene Items aus. +local function FilterActive(flt) + local n = 0 + if next(flt.q) then n = n + 1 end + for _, k in ipairs({ "type", "sub", "slot", "src", "zone", "ilMin", "ilMax", "rlMin", "rlMax", "pMin", "pMax", "aMin", "aMax", "stat", "statMin", "statMax" }) do + if flt[k] ~= nil then n = n + 1 end + end + for i = 1, MAX_STAT_ROWS do + local r = flt.stats and flt.stats[i] + if r and r.key then n = n + 1 end + end + return n +end + +local function PassFilter(flt, facts, info, id) + if flt.src == "vendor" and not (facts.v or (id and SeedOK() and Codex.Seed.ItemHasVendor(id)) or (id and Codex.Forever and Codex.Forever.ItemHasVendor and Codex.Forever.ItemHasVendor(id))) then return false end + if flt.src == "unit" and not facts.u then return false end + if flt.src == "object" and not facts.o then return false end + if flt.src == "reward" and not facts.r then return false end + if flt.src == "quest" and not facts.qi then return false end + if flt.zone and not facts.zones[flt.zone] then return false end + local hasStats = #StatRows(flt) > 0 + local needInfo = next(flt.q) or flt.type or flt.sub or flt.slot or flt.ilMin or flt.ilMax + or flt.rlMin or flt.rlMax or flt.pMin or flt.pMax or flt.aMin or flt.aMax or hasStats or flt.statMin or flt.statMax + if not needInfo then + return true + end + if not info then return false end + if next(flt.q) and not flt.q[info.light and (info.quality or -1) or (info.quality or 1)] then return false end + if flt.type and info.type ~= flt.type then return false end + if flt.sub and info.subType ~= flt.sub then return false end + if flt.slot and info.equipLoc ~= flt.slot then return false end + local il, rl, price = info.itemLevel or 0, info.minLevel or 0, (info.sellPrice or 0) / 10000 + if flt.ilMin and il < flt.ilMin then return false end + if flt.ilMax and il > flt.ilMax then return false end + if flt.rlMin and rl < flt.rlMin then return false end + if flt.rlMax and rl > flt.rlMax then return false end + if flt.pMin and price < flt.pMin then return false end + if flt.pMax and price > flt.pMax then return false end + if flt.aMin or flt.aMax then + local ah = info.id and Codex.AuctionProvider and Codex.AuctionPrice(info.id) + ah = (ah or 0) / 10000 + if not (ah > 0) then return false end + if flt.aMin and ah < flt.aMin then return false end + if flt.aMax and ah > flt.aMax then return false end + end + if hasStats then + local st = info.id and Codex.db.istats and Codex.db.istats[info.id] + if not StatsPass(st, flt) then return false end + elseif flt.statMin or flt.statMax then + if not (info.id and Codex.db.istats and Codex.db.istats[info.id]) then return false end + end + return true +end + +------------------------------------------------------------------------ +-- Eintraege fuer die Liste +------------------------------------------------------------------------ + +local function Match(text, haystack) + return haystack and haystack:lower():find(text, 1, true) ~= nil +end + +-- Suchtext passt auf einen der Namen (Spiel, Deutsch, Englisch)? +local function MatchNames(text, e) + return e and (Match(text, e.n) or Match(text, e.cde) or Match(text, e.cen)) +end + +local function MapOfCells(cells) + local m = Codex.MainMap(cells) + return m and Codex.MapName(m) or nil +end + +-- Zone eines Datensatzes: von Hand gesetzte zuerst, sonst die Hauptkarte der Fundorte +-- "Level 1-4" / "Level 3" fuer eine Gebietsspanne +local function RangeText(lo, hi) + if not lo then return nil end + if not hi or hi == lo then return string.format(FW.L.CODEX_LEVEL, lo) end + return string.format(FW.L.CODEX_LEVEL, lo .. "-" .. hi) +end + +local function ZoneOf(ent) + local z = Codex.ZonesOf(ent)[1] + return z and Codex.MapName(z) or nil +end + +local function ClassLabel(cls) + local L = FW.L + if cls == "elite" or cls == "rareelite" then return L.CODEX_XP_ELITE end + if cls == "rare" then return L.CODEX_XP_RARE end + if cls == "worldboss" then return L.CODEX_XP_BOSS end + return nil +end + +XPTitle = function(r) + local text = string.format(FW.L.CODEX_XP_TITLE, r.pl, r.ml) + local label = ClassLabel(r.cls) + if label then text = text .. " (" .. label .. ")" end + if r.rested then + text = text .. " [" .. FW.L.CODEX_XP_RESTED_TAG .. "]" + elseif r.bonus then + text = text .. " [" .. FW.L.CODEX_XP_BONUS_TAG .. "]" + end + return text +end + +XPKey = function(r) + return r.pl .. ":" .. r.ml .. ":" .. r.cls .. ":" .. (r.rested and "r" or r.bonus and "b" or "a") +end + +-- Vorlage: Eintraege fuer Quests, Kreaturen und Objekte, die es nur in der Vorlage gibt (einmal gebaut und gemerkt) +local seedEntries = {} +local mapNames = {} + +local function CachedMapName(m) + local n = mapNames[m] + if not n then + n = Codex.MapName(m) + mapNames[m] = n + end + return n +end + +local function RankLabel(rank) + local L = FW.L + if rank == 1 or rank == 2 then return L.CODEX_XP_ELITE end + if rank == 3 then return L.CODEX_XP_BOSS end + if rank == 4 then return L.CODEX_XP_RARE end + return nil +end + +local ChainText +local function SeedList(kind) + local key = kind .. ":" .. Codex.Lang() + local cached = seedEntries[key] + if cached then + return cached + end + local L = FW.L + local Seed = Codex.Seed + local list = {} + for _, id in ipairs(Seed.IDs(kind)) do + local e + if kind == "quests" then + local q = Seed.Quest(id) + local title = Seed.QuestTitle(id) or ("Quest #" .. id) + local sub = {} + if q.lvl and q.lvl > 0 then sub[#sub + 1] = string.format(L.CODEX_LEVEL, q.lvl) end + local ref = q.start[1] or q.stop[1] + local zone + if ref and ref.kind ~= "items" then + local m = Seed.MapOf(ref.kind, ref.id) + zone = m and CachedMapName(m) or nil + end + if zone then sub[#sub + 1] = zone end + local chainText = ChainText(id) + if chainText then sub[#sub + 1] = chainText end + sub[#sub + 1] = SeedTag() + e = { id = id, title = title, sub = table.concat(sub, " | "), icon = ICON.quests, name = title:lower(), + recent = 0, seed = true, zone = zone and zone:lower() or "", zoneName = zone, l1 = q.lvl, l2 = q.lvl } + else + local ent = Seed.Entity(kind, id) + local title = Seed.Name(kind, id) or ("#" .. id) + local sub = {} + if kind == "units" and ent.l1 then + sub[#sub + 1] = ent.l1 == ent.l2 and string.format(L.CODEX_LEVEL, ent.l1) or string.format(L.CODEX_LEVEL_RANGE, ent.l1, ent.l2) + end + local m = Seed.MapOf(kind, id) + local zone = m and CachedMapName(m) or nil + if zone then sub[#sub + 1] = zone end + local rank = kind == "units" and RankLabel(ent.rank) or nil + if rank then sub[#sub + 1] = rank end + local roles = RoleNames(kind, id) + if #roles > 0 then sub[#sub + 1] = table.concat(roles, ", ") end + sub[#sub + 1] = SeedTag() + e = { id = id, title = title, sub = table.concat(sub, " | "), icon = ICON[kind], name = title:lower(), recent = 0, + seed = true, gold = -1, unitID = kind == "units" and id or nil, zone = zone and zone:lower() or "", + l1 = ent.l1, l2 = ent.l2 } + end + list[#list + 1] = e + end + seedEntries[key] = list + return list +end + +-- Katalog: Quests, von denen nur Titel und Stufe bekannt sind (gebaut je Stand des Katalogs) +local catEntries, catRev, catLang +local function CatList() + local C = Codex.Catalog + if not C then return {} end + local rev = C.Rev() + if catEntries and catRev == rev then return catEntries end + local L = FW.L + local list = {} + for _, id in ipairs(C.IDs()) do + local title, lv = C.Get(id) + if title then + local sub = {} + if lv then sub[#sub + 1] = string.format(L.CODEX_LEVEL, lv) end + sub[#sub + 1] = "|cff8a8a96[" .. L.CODEX_CAT_TAG .. "]|r" + local meta = C.QuestMeta and C.QuestMeta(id) + if meta then + if meta.zone then table.insert(sub, #sub, meta.zone) end + if meta.info then table.insert(sub, #sub, meta.info) end + end + local zn = meta and meta.zone + list[#list + 1] = { id = id, title = title, sub = table.concat(sub, " | "), icon = ICON.quests, name = title:lower(), + recent = 0, cat = true, zone = zn and zn:lower() or "", zoneName = zn, l1 = lv, l2 = lv } + end + end + catEntries, catRev = list, rev + return list +end + +local function SeedHit(text, kind, e) + if Codex.Seed.Haystack(kind, e.id):find(text, 1, true) then + return true + end + return e.zone ~= nil and e.zone ~= "" and e.zone:find(text, 1, true) ~= nil +end + +local function RolePass(kind, id, role) + if not role then return true end + return Codex.RolesOf(kind, id)[role] ~= nil +end + +local CHAIN_STEPS = { "in", "start", "mid", "end", "none" } + +-- Questkette (nur mit Vorlage bekannt): chain = nil/"in"/"start"/"mid"/"end"/"none" +local function ChainPass(id, chain) + if not chain then return true end + local role = Codex.ChainRole(id) + if chain == "in" then return role ~= nil end + if chain == "none" then return role == nil end + return role == chain +end + +local STATUS_STEPS = { "done", "active", "open" } +local STATUS_MARK = { + done = "Interface\\RaidFrame\\ReadyCheck-Ready", + active = "Interface\\Icons\\Ability_Rogue_Sprint", +} + +local function StatusPass(id, status) + if not status then return true end + local st = Codex.QuestStatus(id) + if status == "open" then return st == nil end + return st == status +end + +-- "Kette 2/4" fuer die Zeile, wenn die Vorlage die Quest in einer Kette kennt +ChainText = function(id) + if not SeedOK() then return nil end + local pos, total = Codex.Seed.ChainPos(id) + if pos then return string.format(FW.L.CODEX_CHAIN_POS, pos, total) end + return nil +end + +-- Kampfdauer einer Kreatur: eigene Klasse, sonst Durchschnitt aller Klassen +local function UnitTTK(u) + if not (Codex.Time and u) then return nil end + local own = Codex.Time.UnitAverage(u) + if own and own > 0 then return own end + local list = Codex.Time.UnitClasses(u) + if #list == 0 then return nil end + local sum = 0 + for _, c in ipairs(list) do sum = sum + c.secs end + local avg = sum / #list + return avg > 0 and avg or nil +end + +-- Erfahrung je Kill der eigenen Stufe (sonst der hoechsten bekannten) +local function UnitXPAvg(u) + if not (u and u.xp) then return nil end + local pl = UnitLevel and UnitLevel("player") or nil + local s = pl and u.xp[pl] + if not s then + local best + for lv, st in pairs(u.xp) do + if not best or lv > best then best, s = lv, st end + end + end + return s and s.n and s.n > 0 and (s.sum / s.n) or nil +end + +-- niedrigster beobachteter Einkaufspreis bei Haendlern (Kupfer) je Item +local function LowestBuyPrices() + local lowest = {} + for _, u in pairs(Codex.V().units) do + for id, w in pairs(u.wa or {}) do + for price in pairs(w.pr or {}) do + if price > 0 and (not lowest[id] or price < lowest[id]) then lowest[id] = price end + end + end + end + return lowest +end + +local function ItemValueOf(id) + local ah = Codex.AuctionProvider and Codex.AuctionPrice(id) + if ah and ah > 0 then return ah end + local info = Util.GetItem(id) + return info and info.sellPrice or nil +end + + +-- Suchkuerzel: stufe:20-25, zone:Durotar, haendler:Ruestung (werden aus dem Suchtext herausgenommen) +local OP_KEYS = { + stufe = "lvl", level = "lvl", lvl = "lvl", lv = "lvl", + zone = "zone", gebiet = "zone", + ["händler"] = "vendor", haendler = "vendor", vendor = "vendor", ["verkäufer"] = "vendor", +} + +local function ParseSearch(text) + local ops + local rest = text:gsub("(%S-):(%S+)", function(key, value) + local op = OP_KEYS[key] + if not op then + return nil + end + ops = ops or {} + if op == "lvl" then + local a, b = value:match("^(%d+)%-(%d+)$") + if not a then a = value:match("^(%d+)$"); b = a end + if not a then return nil end + ops.lvl = { tonumber(a), tonumber(b) } + else + ops[op] = value:gsub("_", " ") + end + return "" + end) + rest = rest:gsub("^%s+", ""):gsub("%s+$", ""):gsub("%s+", " ") + return rest, ops +end + +-- Nachfilter fuer die Kuerzel; e = Listeneintrag (mit l1/l2/zone) +local function OpsPass(kind, e, ops) + if not ops then return true end + if ops.lvl then + local lo, hi = e.l1, e.l2 or e.l1 + if kind == "items" then + local info = Util.GetItem(e.id) + lo = info and info.minLevel or nil + hi = lo + end + if not lo or hi < ops.lvl[1] or lo > ops.lvl[2] then return false end + end + if ops.zone then + if not (e.zone and e.zone:find(ops.zone, 1, true)) then return false end + end + if ops.vendor then + if kind ~= "units" then return false end + local want = ops.vendor + local found = false + local live = Codex.V().units[e.id] + for itemID in pairs(live and live.wa or {}) do + local name = Codex:ItemName(itemID) + if name and name:lower():find(want, 1, true) then found = true break end + end + if not found and SeedOK() then + local roles = Codex.Seed.Roles("units", e.id) + if roles and roles.vendor then + for _, itemID in ipairs(Codex.Seed.VendorItems(e.id)) do + local name = Codex:ItemName(itemID) or Codex.Seed.Name("items", itemID) + if name and name:lower():find(want, 1, true) then found = true break end + end + end + end + if not found then return false end + end + return true +end + + +-- Katalog-Eintraege fuer Kreaturen/Objekte (aus der Server-Abfrage und den Zielen der Quests), je Stand des Katalogs gemerkt +local catEnt, catEntRev = {}, {} +local ctNames = {} +local function CreatureTypeName(t) + if type(t) ~= "number" then return nil end + local n = ctNames[t] + if n == nil then + n = false + local CC = _G.C_CreatureInfo + if CC and CC.GetCreatureTypeInfo then + local ok, ti = pcall(CC.GetCreatureTypeInfo, t) + if ok and type(ti) == "table" and type(ti.name) == "string" then n = ti.name end + end + ctNames[t] = n + end + return n or nil +end + +local function CatEntities(kind) + local C = Codex.Catalog + if not (C and C.Reverse) then return {} end + local rev = C.Rev() .. ":" .. Codex.Lang() + if catEnt[kind] and catEntRev[kind] == rev then return catEnt[kind] end + local L = FW.L + local store = Codex.db and Codex.db.cat and Codex.db.cat[kind == "units" and "c" or "o"] or {} + local targets = C.Reverse()[kind == "units" and "monster" or "object"] + local list, seen = {}, {} + local function Add(id) + if seen[id] then return end + seen[id] = true + local name, subName, f = C.GetEntity(kind == "units" and "c" or "o", id) + local qs = targets[id] + local sub = {} + if f then + if kind == "units" then + local tn = CreatureTypeName(f.type) + if tn then sub[#sub + 1] = tn end + if RankLabel(f.rank) then sub[#sub + 1] = RankLabel(f.rank) end + else + local g = GO_TYPES[f.type] + if g then sub[#sub + 1] = Codex.Lang() == "en" and g[1] or g[2] end + end + end + if subName then sub[#sub + 1] = "<" .. subName .. ">" end + if qs then sub[#sub + 1] = string.format(L.CODEX_CAT_TARGET, #qs) end + sub[#sub + 1] = "|cff8a8a96[" .. L.CODEX_CAT_TAG .. "]|r" + local title = name or ("#" .. id) + list[#list + 1] = { + id = id, title = title, name = title:lower(), subName = subName, sub = table.concat(sub, " | "), + icon = kind == "units" and ICON.units or ICON[kind], recent = 0, cat = true, + unitID = kind == "units" and id or nil, gold = -1, zone = "", + } + end + for id in pairs(store) do if type(id) == "number" then Add(id) end end + for id in pairs(targets) do Add(id) end + catEnt[kind], catEntRev[kind] = list, rev + return list +end + +-- opts: seed = Vorlage anzeigen, role = nur Kreaturen/Objekte mit dieser Rolle, chain = Questkette +local function BuildEntries(kind, text, flt, opts) + local L = FW.L + local db = Codex.V() + opts = opts or {} + text = (text or ""):lower() + local ops + text, ops = ParseSearch(text) + local list = {} + local useSeed = opts.seed and SeedOK() + local role = opts.role + local chain = SeedOK() and opts.chain or nil + local status = opts.status + + if kind == "quests" then + for id, q in pairs(db.quests) do + local zone = q.zone and Codex.MapName(q.zone) or nil + if not zone and Codex.Catalog and Codex.Catalog.QuestMeta then + local m = Codex.Catalog.QuestMeta(id) + zone = m and m.zone or nil + end + local ok = text == "" + or MatchNames(text, q) or Match(text, q.t) or Match(text, zone) + or (q.give and Match(text, q.give.n)) or (q.turn and Match(text, q.turn.n)) + or tostring(id) == text + if ok and ChainPass(id, chain) and StatusPass(id, status) then + local sub = {} + local title = Codex.QuestTitle(q, id) + if q.lvl then sub[#sub + 1] = string.format(L.CODEX_LEVEL, q.lvl) end + if zone then sub[#sub + 1] = zone end + local chainText = ChainText(id) + if chainText then sub[#sub + 1] = chainText end + if (q.done or 0) > 0 then sub[#sub + 1] = string.format(L.CODEX_DONE, q.done) end + list[#list + 1] = { + id = id, title = title, sub = table.concat(sub, " | "), + icon = ICON.quests, name = title:lower(), recent = q.last or 0, + l1 = q.lvl, l2 = q.lvl, zone = zone and zone:lower() or "", zoneName = zone, + } + end + end + if useSeed then + for _, e in ipairs(SeedList("quests")) do + if not db.quests[e.id] and (text == "" or SeedHit(text, "quests", e) or tostring(e.id) == text) + and ChainPass(e.id, chain) and StatusPass(e.id, status) then + list[#list + 1] = e + end + end + end + -- Katalog: nur Titel und Stufe (nicht, wenn es schon einen Datensatz oder die Vorlage gibt) + if not chain then + local seedOn = SeedOK() + for _, e in ipairs(CatList()) do + if not db.quests[e.id] and not (seedOn and Codex.Seed.Quest(e.id)) + and (text == "" or e.name:find(text, 1, true) or tostring(e.id) == text) + and StatusPass(e.id, status) then + list[#list + 1] = e + end + end + end + elseif kind == "units" or kind == "objects" then + local src = kind == "units" and db.units or db.objects + for id, u in pairs(src) do + local zone = ZoneOf(u) + if (text == "" or MatchNames(text, u) or Match(text, zone) or Match(text, u.ti) or tostring(id) == text) + and RolePass(kind, id, role) then + local sub = {} + local lvl = LevelText(u) + if lvl then sub[#sub + 1] = lvl end + if zone then sub[#sub + 1] = zone end + if (u.k or 0) > 0 then sub[#sub + 1] = string.format(L.CODEX_KILLS, u.k) end + local value = Codex.KillValue(u) + if value and value >= 1 then sub[#sub + 1] = string.format(L.CODEX_VALUE_SHORT, FormatMoney(value)) end + local roles = RoleNames(kind, id) + if #roles > 0 then sub[#sub + 1] = table.concat(roles, ", ") end + if u.ti then sub[#sub + 1] = u.ti end + if kind == "units" and u.ct and CT_ICONS[u.ct] then sub[#sub + 1] = L["CODEX_CT_" .. u.ct] end + list[#list + 1] = { + id = id, title = UnitTitle(u, id, kind), sub = table.concat(sub, " | "), + icon = kind == "units" and UnitIcon(u) or ICON[kind], name = (UnitTitle(u, id, kind)):lower(), recent = u.lt or 0, + unitID = kind == "units" and id or nil, + gold = value or -1, + l1 = u.l1, l2 = u.l2, zone = zone and zone:lower() or "", + } + end + end + if useSeed then + for _, e in ipairs(SeedList(kind)) do + if not src[e.id] and (text == "" or SeedHit(text, kind, e) or tostring(e.id) == text) + and RolePass(kind, e.id, role) then + list[#list + 1] = e + end + end + end + -- Katalog: Kreaturen/Objekte aus der Server-Abfrage und Ziele aus den Quest-Einzelheiten + if not role then + for _, e in ipairs(CatEntities(kind)) do + if not src[e.id] and not (useSeed and Codex.Seed.Name(kind, e.id)) + and (text == "" or e.name:find(text, 1, true) or tostring(e.id) == text + or (e.subName and e.subName:lower():find(text, 1, true))) then + list[#list + 1] = e + end + end + end + elseif kind == "zones" then + local pl = UnitLevel and UnitLevel("player") or 1 + for key, z in pairs(Codex.Zones.All()) do + local name = Codex.MapName(key) + local pass = true + if opts.zoneSel == "world" then pass = not z.dungeon + elseif opts.zoneSel == "dungeon" then pass = z.dungeon + elseif opts.zoneSel == "fit" then pass = Codex.Zones.Compare(key, pl, 2) == "in" + elseif opts.zoneSel == "data" then pass = not z.few end + if pass and (text == "" or Match(text, name) or tostring(math.abs(key)) == text) then + local sub = {} + local range = RangeText(z.lo, z.hi) + if range then + sub[#sub + 1] = range .. ((z.avg and not z.manual) and string.format(" (%s %.1f)", L.CODEX_ZONE_AVG, z.avg) or "") + end + if z.manual then sub[#sub + 1] = L.CODEX_ZONE_MANUAL end + if z.few and not z.manual then sub[#sub + 1] = L.CODEX_ZONE_FEW end + sub[#sub + 1] = string.format(L.CODEX_ZONE_UNITS, z.units) + if #z.out > 0 then sub[#sub + 1] = string.format(L.CODEX_ZONE_OUTLIERS, #z.out) end + if #z.special > 0 then sub[#sub + 1] = string.format(L.CODEX_ZONE_SPECIAL, #z.special) end + if z.dungeon then sub[#sub + 1] = L.CODEX_PROPS_DG_YES end + list[#list + 1] = { + id = key, title = name, sub = table.concat(sub, " | "), icon = ICON.zones, name = name:lower(), + recent = z.units, l1 = z.lo, l2 = z.hi, zone = name:lower(), + zlvl = z.lo and (z.lo * 1000 + (z.hi or z.lo)) or -1, cnt = z.units, + } + end + end + elseif kind == "learn" then + local plan = opts.class and Codex.LearnPlan(opts.class) + if plan then + local pl = UnitLevel and UnitLevel("player") or 1 + for _, lv in ipairs(plan.order) do + local g = plan.levels[lv] + local hit = text == "" or tostring(lv) == text + if not hit then + for _, sp in ipairs(g.spells) do + if Match(text, sp.nm) then hit = true break end + end + end + if hit then + local sub = string.format(L.CODEX_LEARN_SUB, #g.spells, FormatMoney(g.cost)) + if lv > pl then + sub = sub .. " | " .. string.format(L.CODEX_LEARN_SUMSHORT, FormatMoney(Codex.LearnCost(plan, pl, lv))) + end + list[#list + 1] = { + id = lv, title = lv > 0 and string.format(L.CODEX_LEARN_TITLE, lv) or L.CODEX_LEARN_LVUNKNOWN, + sub = sub, icon = ICON.learn, name = string.format("%03d", lv), recent = lv, + step = lv > 0 and lv or 1e9, + } + end + end + end + elseif kind == "xp" then + for _, r in ipairs(Codex:XPRows(true)) do + local title = XPTitle(r) + if text == "" or Match(text, title) or tostring(r.pl) == text or tostring(r.ml) == text then + local secs = (not r.bonus and r.cls == "normal") and OwnTTK(r.pl, r.ml) or nil + list[#list + 1] = { + id = XPKey(r), title = title, + sub = string.format(L.CODEX_XP_ROW, r.avg, r.pred, r.diff, r.n) + .. ((secs and secs > 0) and (" | " .. string.format(L.CODEX_TTK_ROW, secs, r.avg / secs * 60)) or ""), + xpm = (secs and secs > 0) and (r.avg / secs * 60) or nil, + icon = ICON.xp, name = string.format("%03d%03d%s%s", 999 - r.pl, r.ml, r.cls, r.bonus and "b" or "a"), + recent = r.n, + } + end + end + else + local index = ItemIndex() + local function ItemEntry(id, facts, name, seed) + local info = Util.GetItem(id) + -- Das Spiel kennt viele Items noch nicht (nicht im Zwischenspeicher). Dann reichen gemerkte Werte: + -- Qualitaet aus dem Merker, Stufe/Mindeststufe/Preis aus dem Katalog. So filtern Qualitaet, Stufe und Preis + -- auch fuer Items, die noch nicht geladen sind (Typ, Platz und Werte brauchen weiter echte Itemdaten). + local light + if not info then + local nm, cq, il, ml, sp = nil, nil, nil, nil, nil + local Cat = Codex.Catalog + if Cat and Cat.GetItem then nm, cq, il, ml, sp = Cat.GetItem(id) end + local q = (Codex.db.iq and Codex.db.iq[id]) or cq + if q or il then + light = { id = id, name = nm, quality = q, itemLevel = il, minLevel = ml, sellPrice = sp, light = true } + end + end + if not PassFilter(flt, facts, info or light, id) then + return + end + if not info then info = light end + local sub = {} + if info then + if (info.itemLevel or 0) > 1 then sub[#sub + 1] = string.format(L.CODEX_ILVL_SHORT, info.itemLevel) end + if info.type then + sub[#sub + 1] = info.type .. ((info.subType and info.subType ~= "" and info.subType ~= info.type) and (" · " .. info.subType) or "") + end + if (info.sellPrice or 0) > 0 then sub[#sub + 1] = FormatMoney(info.sellPrice) end + if info.light then sub[#sub + 1] = "#" .. id end + else + sub[#sub + 1] = "#" .. id + end + if seed then sub[#sub + 1] = SeedTag() end + if facts.ah then sub[#sub + 1] = "|cff8a8a96[" .. L.CODEX_AH_TAG .. "]|r" end + list[#list + 1] = { + id = id, title = name or ("#" .. id), sub = table.concat(sub, " | "), itemID = id, + name = (name or ""):lower(), recent = seed and 0 or id, + price = info and (info.sellPrice or 0) or -1, ilvl = info and (info.itemLevel or 0) or -1, + quality = info and (info.quality or (info.light and -1 or 1)) or -1, chance = facts.best or 0, seed = seed or nil, + } + end + for id, facts in pairs(index) do + local name = Codex:ItemName(id) + if text == "" or Match(text, name) or tostring(id) == text then + ItemEntry(id, facts, name, false) + end + end + -- Items, die es nur in der Vorlage gibt + if useSeed then + local Seed = Codex.Seed + for _, id in ipairs(Seed.IDs("items")) do + if not index[id] and (text == "" or Seed.Haystack("items", id):find(text, 1, true) or tostring(id) == text) then + local it = Seed.Item(id) + local facts = { zones = {}, best = it and it.u[1] and it.u[1].chance / 100 or 0, + u = it and #it.u > 0 or nil, o = it and #it.o > 0 or nil } + ItemEntry(id, facts, Seed.Name("items", id), true) + end + end + end + end + if opts.fav and Codex.FavSupported(kind) then + local kept = {} + for _, e in ipairs(list) do + if Codex.IsFav(kind, e.id) then kept[#kept + 1] = e end + end + list = kept + end + if ops then + local kept = {} + for _, e in ipairs(list) do + if OpsPass(kind, e, ops) then kept[#kept + 1] = e end + end + list = kept + end + return list +end + +------------------------------------------------------------------------ +-- Tab +------------------------------------------------------------------------ + +local function Build(content) + local L = FW.L + local f = CreateFrame("Frame", nil, content) + + local kind, selected, search = "quests", nil, "" + local sortBy, sortRev, flt, filterOpen = {}, {}, { q = {} }, false + local farmOn = false -- Farm-Ranking (Kreaturen) + local history = {} -- Verlauf fuer "Zurueck" + local listRows, detailRows = {}, {} + local Refresh, RefreshList, ShowDetail, Go, UpdateFilterUI, Layout + local nameCtl + local roleSel = {} -- gewaehlte Rolle je Art (Kreaturen, Objekte) + local chainSel = nil -- gewaehlte Questkette (nur Quests) + local statusSel = nil -- gewaehlter Quest-Status (nur Quests) + local zoneSel = nil -- gewaehlte Gebietsart (nur Gebiete) + local learnSel = nil -- gewaehlte Klasse (Lernplan) + local favOnly = {} -- nur Favoriten je Art + local collapsed = {} -- eingeklappte Gebiete (Quests nach Gebiet) + local dataVersion = 0 -- zaehlt jede Datenaenderung; merkt sich die sortierte Liste dazwischen + local memo = {} + + local function SeedOn() + return SeedOK() and Codex.db.settings.showSeed ~= false + end + + ---------------- Kopf: Arten ---------------- + local kindButtons = {} + local KIND_GAP = 4 + for i, k in ipairs(KINDS) do local k = k + local b = UI.CreateButton(f, L[k.label], 60, 28, function() + kind, selected = k.id, nil + Refresh() + end, "default") + kindButtons[k.id] = b + end + -- "Erfahrung" ist ein Werkzeug fuer Entwickler: der Knopf erscheint nur mit dem Modul Freewood-OctoWoW-Dev + local function KindVisible(id) + return id ~= "xp" or FW.Dev ~= nil + end + local function LayoutKinds() + local shown = {} + for _, k in ipairs(KINDS) do + if KindVisible(k.id) then shown[#shown + 1] = k end + end + local w = math.floor((LIST_W - KIND_GAP * (#shown - 1)) / #shown) + for _, k in ipairs(KINDS) do + local b = kindButtons[k.id] + if KindVisible(k.id) then + local idx + for i, s in ipairs(shown) do if s == k then idx = i end end + b:SetWidth(w) + b:ClearAllPoints() + b:SetPoint("TOPLEFT", f, "TOPLEFT", (idx - 1) * (w + KIND_GAP), 0) + b:Show() + -- OctoWoW: die Schrift ist breiter als im Forever-Client; Text verkleinern, bis er in den Knopf passt + local size = 12 + UI.SetFont(b.label, "body", size) + while size > 8 and (b.label:GetStringWidth() or 0) > w - 8 do + size = size - 1 + UI.SetFont(b.label, "body", size) + end + else + b:Hide() + end + end + if not KindVisible(kind) then kind, selected = "quests", nil end + end + LayoutKinds() + + ---------------- links: Suche und Liste ---------------- + local box = UI.CreatePanel(f, "bgDeep", "bronze") + box:SetHeight(26) + box:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -38) + box:SetWidth(LIST_W - 188 - 30) + 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(60) + 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) + local hint = UI.CreateLabel(box, L.CODEX_SEARCH, { size = 12, color = "textDim" }) + hint:SetPoint("LEFT", box, "LEFT", 10, 0) + edit:SetScript("OnTextChanged", function(self) + search = self:GetText() or "" + if search == "" then hint:Show() else hint:Hide() end + if Refresh then Refresh() end + end) + + local function CurrentSort() + return sortBy[kind] or SORTS[kind][1] + end + local function SortModes() + local list = {} + for _, m in ipairs(SORTS[kind]) do + if (m ~= "ah" and m ~= "ahkill" and m ~= "gpmah" and m ~= "flip") or Codex.AuctionProvider then + list[#list + 1] = { value = m, text = L["CODEX_SORTBY_" .. m] or m } + end + end + return list + end + local sortDD = UI.CreateDropdown(f, 150, 26, { + getOptions = SortModes, + textOf = function(v) return L["CODEX_SORTBY_" .. v] or v end, + onChange = function(v) + sortBy[kind] = v + sortRev[kind] = false + Refresh() + end, + }) + sortDD:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W - 150, -38) + local dirButton = UI.CreateButton(f, "", 26, 26, function() + sortRev[kind] = not sortRev[kind] + Refresh() + end, "default") + dirButton:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W - 180, -38) + local dirTip = function(self) + if GameTooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(L.CODEX_SORT_DIR_TIP) + GameTooltip:Show() + end + end + if dirButton.HookScript then + dirButton:HookScript("OnEnter", dirTip) + dirButton:HookScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end) + end + -- Pfeil als Textur: haengt davon ab, ob aktuell aufsteigend sortiert wird + dirButton.arrow = dirButton:CreateTexture(nil, "OVERLAY") + -- derselbe Pfeil wie bei der Wegfindung (zeigt nach oben); abwaerts wird er gespiegelt + dirButton.arrow:SetTexture("Interface\\Minimap\\ROTATING-MINIMAPARROW") + dirButton.arrow:SetVertexColor(UI.Color("goldLight")) + dirButton.arrow:SetSize(16, 16) + dirButton.arrow:SetPoint("CENTER", dirButton, "CENTER", 0, 0) + + -- nur Favoriten (Stern neben der Suche) + local favToggle = UI.CreateButton(f, "", 26, 26, function() + favOnly[kind] = not favOnly[kind] + dataVersion = dataVersion + 1 + Refresh() + end, "default") + favToggle:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W - 188 - 26, -38) + favToggle.star = favToggle:CreateTexture(nil, "OVERLAY") + -- gelber Stern aus dem Zielmarkierungs-Bogen (Feld oben links) + favToggle.star:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons") + favToggle.star:SetTexCoord(0, 0.25, 0, 0.25) + favToggle.star:SetSize(16, 16) + favToggle.star:SetPoint("CENTER", favToggle, "CENTER", 0, 0) + if favToggle.HookScript then + favToggle:HookScript("OnEnter", function(self) + if GameTooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(L.CODEX_FAV_ONLY) + GameTooltip:Show() + end + end) + favToggle:HookScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end) + end + + local countLabel = UI.CreateLabel(f, "", { size = 11, color = "textDim" }) + countLabel:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -72) + countLabel:SetWidth(LIST_W) + countLabel:SetWordWrap(false) + + ---------------- Filter (nur Items) ---------------- + local ROW, PAD = 26, 4 + local function FilterH() + return (Codex.AuctionProvider and 9 or 8) * (ROW + PAD) - PAD + end + local FILTER_H = FilterH() + local fp = CreateFrame("Frame", nil, f) + fp:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -92) + fp:SetSize(LIST_W, FILTER_H) + fp:Hide() + local syncing = false + local qualityButtons, dropdowns, numBoxes, opButtons = {}, {}, {}, {} + local previewLabel, ahFilter + + local function RowY(row) return -(row * (ROW + PAD)) end + + local qw = math.floor((LIST_W - 5 * PAD) / 6) + for q = 0, 5 do local q = q + local b = UI.CreateButton(fp, Colored(L["CODEX_QUAL_" .. q], q), qw, ROW - 2, function() + if flt.q[q] then flt.q[q] = nil else flt.q[q] = true end + UpdateFilterUI() + RefreshList() + end, "default") + b:SetPoint("TOPLEFT", fp, "TOPLEFT", q * (qw + PAD), RowY(0)) + qualityButtons[q] = b + end + + local function InfoValues(getter, pred) + local seen, list = {}, {} + for id in pairs((ItemIndex())) do + local info = Util.GetItem(id) + if info and (not pred or pred(info)) then + local v = getter(info) + if v and v ~= "" and not seen[v] then + seen[v] = true + list[#list + 1] = v + end + end + end + table.sort(list, function(a, b) return tostring(a) < tostring(b) end) + return list + end + + local SRC_KEYS = { "unit", "object", "reward", "quest", "vendor" } + local function SrcLabel(v) return L["CODEX_SRC_" .. v:upper()] or v end + local function Zones() + local seen, list = {}, {} + for _, facts in pairs((ItemIndex())) do + for m in pairs(facts.zones) do + if not seen[m] then seen[m] = true; list[#list + 1] = m end + end + end + table.sort(list, function(a, b) return Codex.MapName(a) < Codex.MapName(b) end) + return list + end + + -- Liste von Werten in Dropdown-Eintraege umwandeln + local function AsOptions(values, display) + local list = {} + for _, v in ipairs(values) do + list[#list + 1] = { value = v, text = display and display(v) or tostring(v) } + end + return list + end + local function Prefix(key) return (L[key]:gsub(":%s*%%s%s*$", "")) end + + local third = math.floor((LIST_W - 2 * PAD) / 3) + local function MakeDrop(x, row, w, prefix, getValue, setValue, optionsFn, display) + local dd = UI.CreateDropdown(fp, w, ROW - 2, { + prefix = prefix, allText = L.CODEX_FILTER_ALL, + getOptions = function() return AsOptions(optionsFn(), display) end, + textOf = function(v) return display and display(v) or tostring(v) end, + onChange = function(v) + setValue(v) + UpdateFilterUI() + RefreshList() + end, + }) + dd:SetPoint("TOPLEFT", fp, "TOPLEFT", x, RowY(row)) + dropdowns[#dropdowns + 1] = { dd = dd, get = getValue } + return dd + end + local function FieldDrop(col, row, labelKey, field, optionsFn, display, onSet) + return MakeDrop(col * (third + PAD), row, third, Prefix(labelKey), + function() return flt[field] end, + function(v) flt[field] = v; if onSet then onSet() end end, optionsFn, display) + end + FieldDrop(0, 1, "CODEX_FILTER_TYPE", "type", function() return InfoValues(function(i) return i.type end) end, tostring, + function() flt.sub = nil end) + FieldDrop(1, 1, "CODEX_FILTER_SUB", "sub", function() + return InfoValues(function(i) return i.subType end, function(i) return not flt.type or i.type == flt.type end) + end, tostring) + FieldDrop(2, 1, "CODEX_FILTER_SLOT", "slot", function() return InfoValues(function(i) return i.equipLoc end) end, + function(v) return _G[v] or v end) + FieldDrop(0, 2, "CODEX_FILTER_SRC", "src", function() return SRC_KEYS end, SrcLabel) + FieldDrop(1, 2, "CODEX_FILTER_ZONE", "zone", Zones, Codex.MapName) + + local resetButton = UI.CreateButton(fp, L.CODEX_FILTER_RESET, third, ROW - 2, function() + flt = { q = {} } + UpdateFilterUI() + RefreshList() + end, "default") + resetButton:SetPoint("TOPLEFT", fp, "TOPLEFT", 2 * (third + PAD), RowY(2)) + + local NUM_W = 38 + local function MakeNum(x, row, getValue, setValue) + local box = UI.CreatePanel(fp, "bgDeep", "bronze") + box:SetSize(NUM_W, ROW - 2) + box:SetPoint("TOPLEFT", fp, "TOPLEFT", x, RowY(row)) + local e = CreateFrame("EditBox", nil, box) + e:SetAutoFocus(false) + e:SetPoint("TOPLEFT", box, "TOPLEFT", 5, -2) + e:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", -5, 2) + e:SetMaxLetters(7) + if not e:SetFont(UI.GetFontPath("body"), 11, "") then + e:SetFont("Fonts\\FRIZQT__.TTF", 11, "") + end + e:SetTextColor(UI.Color("text")) + e:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + e:SetScript("OnTextChanged", function(self) + if syncing then return end + setValue(tonumber(((self:GetText() or ""):gsub(",", ".")))) + UpdateFilterUI() + RefreshList() + end) + numBoxes[#numBoxes + 1] = { e = e, box = box, get = getValue } + end + local function FieldNum(x, row, field) + MakeNum(x, row, function() return flt[field] end, function(v) flt[field] = v end) + end + local function NumLabel(x, row, text, w) + local l = UI.CreateLabel(fp, text, { size = 10, color = "textDim" }) + l:SetPoint("TOPLEFT", fp, "TOPLEFT", x, RowY(row) - 7) + l:SetWidth(w) + l:SetWordWrap(false) + end + -- Zeile 3: Itemstufe, Benoetigt, Preis, je mit Min und Max + local LBL_W = math.max(44, math.min(92, third - 2 * NUM_W - 8)) -- Beschriftung bekommt den Platz, der neben den zwei Feldern bleibt + local groupsDef = { { "CODEX_FILTER_ILVL", "ilMin", "ilMax" }, { "CODEX_FILTER_RLVL", "rlMin", "rlMax" }, + { "CODEX_FILTER_PRICE", "pMin", "pMax" } } + for i, g in ipairs(groupsDef) do + local x0 = (i - 1) * (third + PAD) + NumLabel(x0, 3, L[g[1]], LBL_W) + FieldNum(x0 + LBL_W, 3, g[2]) + FieldNum(x0 + LBL_W + NUM_W + 2, 3, g[3]) + end + + -- Zeilen 4 bis 6: bis zu drei Werte, dahinter UND/ODER fuer die naechste Zeile + local OP_W = 62 + local statDropW = LIST_W - 2 * NUM_W - OP_W - 3 * PAD + local function StatRow(i) + flt.stats = flt.stats or {} + flt.stats[i] = flt.stats[i] or {} + return flt.stats[i] + end + local function StatOptions() + local _, ids = ItemIndex() + Codex:CaptureStats(ids) + return Codex:StatKeys(ids) + end + for i = 1, MAX_STAT_ROWS do local i = i + local row = 3 + i + MakeDrop(0, row, statDropW, Prefix("CODEX_FILTER_STAT") .. " " .. i, + function() return flt.stats and flt.stats[i] and flt.stats[i].key end, + function(v) + local r = StatRow(i) + r.key = v + if v == nil then r.min, r.max = nil, nil end + end, StatOptions, Codex.StatName) + MakeNum(statDropW + PAD, row, + function() return flt.stats and flt.stats[i] and flt.stats[i].min end, + function(v) StatRow(i).min = v end) + MakeNum(statDropW + PAD + NUM_W + PAD, row, + function() return flt.stats and flt.stats[i] and flt.stats[i].max end, + function(v) StatRow(i).max = v end) + if i < MAX_STAT_ROWS then + local b = UI.CreateButton(fp, "", OP_W, ROW - 2, function() + flt.ops = flt.ops or {} + flt.ops[i] = (flt.ops[i] == "or") and "and" or "or" + UpdateFilterUI() + RefreshList() + end, "default") + b:SetPoint("TOPLEFT", fp, "TOPLEFT", statDropW + 2 * PAD + 2 * NUM_W + PAD, RowY(row)) + opButtons[i] = b + end + end + previewLabel = UI.CreateLabel(fp, "", { size = 10, color = "textDim" }) + previewLabel:SetPoint("TOPLEFT", fp, "TOPLEFT", 2, RowY(7) - 4) + -- Zeile 8: Auktionspreis (nur mit Auktions-Modul) + ahFilter = {} + ahFilter.label = UI.CreateLabel(fp, L.CODEX_FILTER_AH, { size = 10, color = "textDim" }) + ahFilter.label:SetPoint("TOPLEFT", fp, "TOPLEFT", 0, RowY(8) - 7) + ahFilter.label:SetWidth(LBL_W + 20) + ahFilter.label:SetWordWrap(false) + MakeNum(LBL_W + 24, 8, function() return flt.aMin end, function(v) flt.aMin = v end) + MakeNum(LBL_W + 24 + NUM_W + 2, 8, function() return flt.aMax end, function(v) flt.aMax = v end) + numBoxes[#numBoxes].ah, numBoxes[#numBoxes - 1].ah = true, true + previewLabel:SetWidth(LIST_W - 4) + previewLabel:SetWordWrap(false) + + local filterButton = UI.CreateButton(f, L.CODEX_FILTER, 96, 20, function() + filterOpen = not filterOpen + Layout() + end, "default") + filterButton:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W - 96, -68) + filterButton:Hide() + + -- Vorlage (Vanilla-Startdaten) ein/aus und Rolle (Haendler, Flugmeister ...) + local seedButton = UI.CreateButton(f, "", 100, 20, function() + Codex.db.settings.showSeed = not SeedOn() + dataVersion = dataVersion + 1 + Refresh() + end, "default") + seedButton:Hide() + -- Auswahl-Listen unter der Zaehlzeile: Rolle (Kreaturen, Objekte), Kette und Status (Quests) + local function BumpAndRefresh() + dataVersion = dataVersion + 1 + Refresh() + end + local roleDD = UI.CreateDropdown(f, 186, 20, { + prefix = Prefix("CODEX_FILTER_ROLE"), allText = L.CODEX_FILTER_ALL, + getOptions = function() + local list = {} + for _, r in ipairs(Codex.Roles.ORDER[kind] or {}) do + list[#list + 1] = { value = r, text = L["CODEX_ROLE_" .. r] or r } + end + return list + end, + textOf = function(v) return L["CODEX_ROLE_" .. v] or v end, + onChange = function(v) roleSel[kind] = v; Refresh() end, + }) + roleDD:Hide() + local chainDD = UI.CreateDropdown(f, 186, 20, { + prefix = Prefix("CODEX_FILTER_CHAIN"), allText = L.CODEX_FILTER_ALL, + getOptions = function() + local list = {} + for _, c in ipairs(CHAIN_STEPS) do list[#list + 1] = { value = c, text = L["CODEX_CHAIN_" .. c:upper()] } end + return list + end, + textOf = function(v) return L["CODEX_CHAIN_" .. v:upper()] end, + onChange = function(v) chainSel = v; BumpAndRefresh() end, + }) + chainDD:Hide() + local statusDD = UI.CreateDropdown(f, 186, 20, { + prefix = Prefix("CODEX_FILTER_STATUS"), allText = L.CODEX_FILTER_ALL, + getOptions = function() + local list = {} + for _, c in ipairs(STATUS_STEPS) do list[#list + 1] = { value = c, text = L["CODEX_STATUS_" .. c:upper()] } end + return list + end, + textOf = function(v) return L["CODEX_STATUS_" .. v:upper()] end, + onChange = function(v) statusSel = v; BumpAndRefresh() end, + }) + statusDD:Hide() + local ZONE_STEPS = { "world", "dungeon", "fit", "data" } + local zoneDD = UI.CreateDropdown(f, 186, 20, { + prefix = Prefix("CODEX_FILTER_ZONE"), allText = L.CODEX_FILTER_ALL, + getOptions = function() + local list = {} + for _, c in ipairs(ZONE_STEPS) do list[#list + 1] = { value = c, text = L["CODEX_ZONEF_" .. c:upper()] } end + return list + end, + textOf = function(v) return L["CODEX_ZONEF_" .. v:upper()] end, + onChange = function(v) zoneSel = v; BumpAndRefresh() end, + }) + zoneDD:Hide() + -- Klasse des Lernplans: die eigene, sonst die erste mit Daten + local function EnsureLearnSel() + local classes = Codex.LearnClasses() + local have = {} + for _, t in ipairs(classes) do have[t] = true end + if not (learnSel and have[learnSel]) then + local mine = Codex.Time and Codex.Time.PlayerToken() + learnSel = (mine and have[mine]) and mine or classes[1] + end + return learnSel + end + local learnDD = UI.CreateDropdown(f, 186, 20, { + prefix = Prefix("CODEX_LEARN_CLASS"), + getOptions = function() + local list = {} + for _, t in ipairs(Codex.LearnClasses()) do + list[#list + 1] = { value = t, text = Codex.Time.ClassLabel(t) } + end + return list + end, + textOf = function(v) return Codex.Time.ClassLabel(v) end, + onChange = function(v) learnSel = v; selected = nil; BumpAndRefresh() end, + }) + learnDD:Hide() + -- Routen-Planer: Stufenbereich (leer = keine Grenze), Haken = +/- 3 Stufen + local routeOpen, routeSyncing = false, false + local routeLvl = {} + local ROUTE_H = 62 + local rp = CreateFrame("Frame", nil, f) + rp:SetSize(LIST_W, ROUTE_H) + rp:Hide() + local routeBoxes = {} + local routeCheck + local function RouteBox(x, getValue, setValue) + local box = UI.CreatePanel(rp, "bgDeep", "bronze") + box:SetSize(38, 22) + box:SetPoint("TOPLEFT", rp, "TOPLEFT", x, -34) + local e = CreateFrame("EditBox", nil, box) + e:SetAutoFocus(false) + e:SetPoint("TOPLEFT", box, "TOPLEFT", 5, -2) + e:SetPoint("BOTTOMRIGHT", box, "BOTTOMRIGHT", -5, 2) + e:SetMaxLetters(3) + if not e:SetFont(UI.GetFontPath("body"), 11, "") then e:SetFont("Fonts\\FRIZQT__.TTF", 11, "") end + e:SetTextColor(UI.Color("text")) + e:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + e:SetScript("OnTextChanged", function(self) + if routeSyncing then return end + local v = tonumber(self:GetText() or "") + setValue(v and math.max(0, math.floor(v)) or nil) + if routeCheck then routeCheck:Refresh() end + end) + routeBoxes[#routeBoxes + 1] = { e = e, get = getValue } + end + local function RouteSync() + routeSyncing = true + for _, b in ipairs(routeBoxes) do + local v = b.get() + local text = v ~= nil and tostring(v) or "" + if b.e:GetText() ~= text and not b.e:HasFocus() then b.e:SetText(text) end + end + routeSyncing = false + if routeCheck then routeCheck:Refresh() end + end + routeCheck = UI.CreateCheckbox(rp, L.CODEX_ROUTE_RANGE, function() + return routeLvl.below == 3 and routeLvl.above == 3 + end, function(on) + if on then routeLvl.below, routeLvl.above = 3, 3 else routeLvl.below, routeLvl.above = nil, nil end + RouteSync() + end) + routeCheck:SetPoint("TOPLEFT", rp, "TOPLEFT", 0, -2) + local lblBelow = UI.CreateLabel(rp, L.CODEX_ROUTE_BELOW, { size = 10, color = "textDim" }) + lblBelow:SetPoint("TOPLEFT", rp, "TOPLEFT", 0, -41) + lblBelow:SetWidth(86) + lblBelow:SetWordWrap(false) + RouteBox(88, function() return routeLvl.below end, function(v) routeLvl.below = v end) + local lblAbove = UI.CreateLabel(rp, L.CODEX_ROUTE_ABOVE, { size = 10, color = "textDim" }) + lblAbove:SetPoint("TOPLEFT", rp, "TOPLEFT", 140, -41) + lblAbove:SetWidth(86) + lblAbove:SetWordWrap(false) + RouteBox(228, function() return routeLvl.above end, function(v) routeLvl.above = v end) + local routeGo = UI.CreateButton(rp, L.CODEX_ROUTE_GO, 140, 22, function() + local ids = {} + for _, e in ipairs(f.Entries()) do ids[#ids + 1] = e.id end + Codex.Map:StartRoute(ids, (routeLvl.below or routeLvl.above) and { below = routeLvl.below, above = routeLvl.above } or nil) + end, "primary") + routeGo:SetPoint("TOPLEFT", rp, "TOPLEFT", 280, -33) + local routeButton = UI.CreateButton(f, L.CODEX_ROUTE, 96, 20, function() + routeOpen = not routeOpen + RouteSync() + Layout() + end, "default") + if routeButton.HookScript then + routeButton:HookScript("OnEnter", function(self) + if GameTooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(L.CODEX_ROUTE_TIP, nil, nil, nil, nil, true) + GameTooltip:Show() + end + end) + routeButton:HookScript("OnLeave", function() if GameTooltip then GameTooltip:Hide() end end) + end + routeButton:Hide() + + function UpdateFilterUI() + for q, b in pairs(qualityButtons) do + b:SetStyle(flt.q[q] and "primary" or "default") + end + for _, d in ipairs(dropdowns) do + d.dd:SetValue(d.get()) + end + syncing = true + for _, n in ipairs(numBoxes) do + local v = n.get() + local text = v ~= nil and tostring(v) or "" + if n.e:GetText() ~= text and not n.e:HasFocus() then + n.e:SetText(text) + end + end + syncing = false + for i, b in pairs(opButtons) do + local isOr = flt.ops and flt.ops[i] == "or" + b:SetLabel(isOr and L.CODEX_OP_OR or L.CODEX_OP_AND) + b:SetStyle(isOr and "primary" or "default") + end + previewLabel:SetText(StatPreview(flt)) + local n = FilterActive(flt) + filterButton:SetLabel(n > 0 and string.format(L.CODEX_FILTER_N, n) or L.CODEX_FILTER) + filterButton:SetStyle(n > 0 and "primary" or "default") + end + + local farmButton = UI.CreateButton(f, L.CODEX_FARM, 120, 20, function() + farmOn = not farmOn + if farmOn then + roleSel.units = "combat" + sortBy.units = "gpm" + sortRev.units = false + else + roleSel.units = nil + sortBy.units = nil + end + Refresh() + end, "default") + farmButton:Hide() + local backButton = UI.CreateButton(f, L.CODEX_BACK, 100, 26, function() + local prev = table.remove(history) + if prev then + kind, selected = prev.kind, prev.id + edit:SetText("") + edit:ClearFocus() + Refresh() + end + end, "default") + backButton:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 22, 0) + backButton:Hide() + + local listScroll = UI.CreateScrollFrame(f) + listScroll:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -92) + listScroll:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, 0) + listScroll:SetWidth(LIST_W) + + function Layout() + local isItems = kind == "items" + local open = isItems and filterOpen + if isItems then filterButton:Show() else filterButton:Hide() end + if open then fp:Show() else fp:Hide() end + -- rechts neben dem Zaehler: [Vorlage] [Filter] + local right = LIST_W - (isItems and 100 or 0) + local hasSeed = SeedOK() and kind ~= "xp" and kind ~= "learn" + if hasSeed then + right = right - 100 + seedButton:ClearAllPoints() + seedButton:SetPoint("TOPLEFT", f, "TOPLEFT", right, -68) + seedButton:SetLabel(SeedOn() and L.CODEX_SEED_ON or L.CODEX_SEED_OFF) + seedButton:SetStyle(SeedOn() and "primary" or "default") + seedButton:Show() + else + seedButton:Hide() + end + countLabel:SetWidth(math.max(60, right - 6)) + -- eigene Zeile fuer die Auswahl-Listen: Rolle, Kette, Status + local extra = false + local hasRole = Codex.Roles.ORDER[kind] ~= nil + if hasRole then + roleDD:ClearAllPoints() + roleDD:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -94) + roleDD:SetValue(roleSel[kind]) + roleDD:Show() + extra = true + else + roleDD:Hide() + end + if kind == "units" then + farmButton:ClearAllPoints() + farmButton:SetPoint("TOPLEFT", f, "TOPLEFT", 192, -94) + farmButton:SetStyle(farmOn and "primary" or "default") + farmButton:Show() + else + farmButton:Hide() + end + local isQuests = kind == "quests" + local x = 0 + if isQuests and hasSeed then + chainDD:ClearAllPoints() + chainDD:SetPoint("TOPLEFT", f, "TOPLEFT", x, -94) + chainDD:SetValue(chainSel) + chainDD:Show() + x = x + 190 + extra = true + else + chainDD:Hide() + end + if isQuests then + statusDD:ClearAllPoints() + statusDD:SetPoint("TOPLEFT", f, "TOPLEFT", x, -94) + statusDD:SetValue(statusSel) + statusDD:Show() + routeButton:ClearAllPoints() + routeButton:SetPoint("TOPLEFT", f, "TOPLEFT", x + 190, -94) + routeButton:Show() + extra = true + else + statusDD:Hide() + routeButton:Hide() + end + if kind == "zones" then + zoneDD:ClearAllPoints() + zoneDD:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -94) + zoneDD:SetValue(zoneSel) + zoneDD:Show() + extra = true + else + zoneDD:Hide() + end + if Codex.FavSupported(kind) then + favToggle:Show() + favToggle:SetStyle(favOnly[kind] and "primary" or "default") + else + favToggle:Hide() + end + if kind == "learn" and EnsureLearnSel() then + learnDD:ClearAllPoints() + learnDD:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -94) + learnDD:SetValue(learnSel) + learnDD:Show() + extra = true + else + learnDD:Hide() + end + local top = 92 + (extra and 28 or 0) + local showRoute = kind == "quests" and routeOpen + if showRoute then + rp:ClearAllPoints() + rp:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -top) + rp:Show() + else + rp:Hide() + end + routeButton:SetStyle(showRoute and "primary" or "default") + if showRoute then top = top + ROUTE_H + 6 end + listScroll:ClearAllPoints() + FILTER_H = FilterH() + fp:SetHeight(FILTER_H) + if ahFilter then ahFilter.label:SetShown(Codex.AuctionProvider ~= nil) end + for _, nb in ipairs(numBoxes) do + if nb.ah then nb.box:SetShown(Codex.AuctionProvider ~= nil) end + end + listScroll:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -(top + (open and (FILTER_H + 6) or 0))) + listScroll:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, 0) + listScroll:SetWidth(LIST_W) + end + local listEmpty = UI.CreateLabel(listScroll.child, "", { size = 12, color = "textDim", justifyV = "TOP" }) + listEmpty:SetPoint("TOPLEFT", listScroll.child, "TOPLEFT", 4, -6) + listEmpty:SetPoint("TOPRIGHT", listScroll.child, "TOPRIGHT", -4, -6) + listEmpty:SetWordWrap(true) + + local separator = f:CreateTexture(nil, "ARTWORK") + separator:SetColorTexture(UI.Color("bronze", 0.5)) + separator:SetWidth(1) + separator:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 10, 0) + separator:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", LIST_W + 10, 0) + + ---------------- rechts: Detail ---------------- + local detail = UI.CreateScrollFrame(f) + detail:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 22, -34) + detail:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0) + local detailEmpty = UI.CreateLabel(detail.child, L.CODEX_SELECT, { size = 12, color = "textDim" }) + detailEmpty:SetPoint("TOPLEFT", detail.child, "TOPLEFT", 4, -6) + + -- Namen von Hand eintragen (Deutsch / Englisch) + local UpdateFavUIRef + if Codex.NameUI then + nameCtl = Codex.NameUI.Attach(f, function() return kind, selected end, function() + dataVersion = dataVersion + 1 + Refresh() + end, { x = LIST_W + 22, y = 34, relayout = function() if UpdateFavUIRef then UpdateFavUIRef() end end }) + end + + ---------------- Favorit und Notiz ---------------- + local favButton = UI.CreateButton(f, L.CODEX_FAV_ADD, 140, 26, function() + if selected ~= nil and Codex.FavSupported(kind) then + Codex.SetFav(kind, selected, not Codex.IsFav(kind, selected)) + dataVersion = dataVersion + 1 + Refresh() + end + end, "default") + favButton:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 22 + 108, 0) + favButton:Hide() + local noteBox = UI.CreatePanel(f, "bgDeep", "bronze") + noteBox:SetHeight(24) + noteBox:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 22, -34) + noteBox:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, -34) + noteBox:Hide() + local noteEdit = CreateFrame("EditBox", nil, noteBox) + noteEdit:SetAutoFocus(false) + noteEdit:SetPoint("TOPLEFT", noteBox, "TOPLEFT", 8, -2) + noteEdit:SetPoint("BOTTOMRIGHT", noteBox, "BOTTOMRIGHT", -8, 2) + noteEdit:SetMaxLetters(200) + if not noteEdit:SetFont(UI.GetFontPath("body"), 12, "") then + noteEdit:SetFont("Fonts\\FRIZQT__.TTF", 12, "") + end + noteEdit:SetTextColor(UI.Color("text")) + local noteHint = UI.CreateLabel(noteBox, L.CODEX_NOTE_HINT, { size = 11, color = "textDim" }) + noteHint:SetPoint("LEFT", noteBox, "LEFT", 10, 0) + local noteTarget = nil + local function SaveNote() + if noteTarget then Codex.SetNote(noteTarget.kind, noteTarget.id, noteEdit:GetText()) end + end + noteEdit:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + noteEdit:SetScript("OnEnterPressed", function(self) SaveNote(); self:ClearFocus() end) + noteEdit:SetScript("OnEditFocusLost", function() SaveNote() end) + noteEdit:SetScript("OnTextChanged", function(self) + if (self:GetText() or "") == "" then noteHint:Show() else noteHint:Hide() end + end) + + -- Dev-Notiz (nur mit dem Modul Freewood-OctoWoW-Dev und Bearbeitungsrecht): lokal, wird nicht geteilt + local devNoteBox = UI.CreatePanel(f, "bgDeep", "gold") + devNoteBox:SetHeight(24) + devNoteBox:Hide() + local devNoteEdit = CreateFrame("EditBox", nil, devNoteBox) + devNoteEdit:SetAutoFocus(false) + devNoteEdit:SetPoint("TOPLEFT", devNoteBox, "TOPLEFT", 8, -2) + devNoteEdit:SetPoint("BOTTOMRIGHT", devNoteBox, "BOTTOMRIGHT", -8, 2) + devNoteEdit:SetMaxLetters(500) + if not devNoteEdit:SetFont(UI.GetFontPath("body"), 12, "") then + devNoteEdit:SetFont("Fonts\\FRIZQT__.TTF", 12, "") + end + devNoteEdit:SetTextColor(UI.Color("text")) + local devNoteHint = UI.CreateLabel(devNoteBox, L.CODEX_DEVNOTE_HINT, { size = 11, color = "textDim" }) + devNoteHint:SetPoint("LEFT", devNoteBox, "LEFT", 10, 0) + local devNoteTarget = nil + local function SaveDevNote() + if devNoteTarget then Codex.SetDevNote(devNoteTarget.kind, devNoteTarget.id, devNoteEdit:GetText()) end + end + devNoteEdit:SetScript("OnEscapePressed", function(self) self:ClearFocus() end) + devNoteEdit:SetScript("OnEnterPressed", function(self) SaveDevNote(); self:ClearFocus() end) + devNoteEdit:SetScript("OnEditFocusLost", function() SaveDevNote() end) + devNoteEdit:SetScript("OnTextChanged", function(self) + if (self:GetText() or "") == "" then devNoteHint:Show() else devNoteHint:Hide() end + end) + + local function UpdateFavUI() + local ok = selected ~= nil and Codex.FavSupported(kind) + local same = noteTarget and noteTarget.kind == kind and noteTarget.id == selected + if noteEdit:HasFocus() then + if same and ok and Codex.IsFav(kind, selected) then return end + SaveNote() + noteEdit:ClearFocus() + end + noteTarget = nil + local devH = nameCtl and nameCtl.height or 0 -- Reihe mit den Bearbeiten-Knoepfen (nur fuer Berechtigte) + local top = 34 + devH + noteBox:ClearAllPoints() + noteBox:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 22, -top) + noteBox:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, -top) + if ok then + local fav = Codex.IsFav(kind, selected) + favButton:Show() + favButton:SetLabel(fav and L.CODEX_FAV_REMOVE or L.CODEX_FAV_ADD) + favButton:SetStyle(fav and "primary" or "default") + if fav then + noteTarget = { kind = kind, id = selected } + local e = Codex.GetFav(kind, selected) + noteEdit:SetText(e and e.note or "") + noteBox:Show() + top = top + 30 + else + noteBox:Hide() + end + else + favButton:Hide() + noteBox:Hide() + end + -- Dev-Notiz + if devNoteEdit:HasFocus() then SaveDevNote() devNoteEdit:ClearFocus() end + devNoteTarget = nil + if selected ~= nil and Codex.DevNoteSupported(kind) and Codex.CanDevNote() then + devNoteTarget = { kind = kind, id = selected } + devNoteBox:ClearAllPoints() + devNoteBox:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 22, -top) + devNoteBox:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, -top) + devNoteEdit:SetText(Codex.GetDevNote(kind, selected) or "") + devNoteBox:Show() + top = top + 30 + else + devNoteBox:Hide() + end + detail:ClearAllPoints() + detail:SetPoint("TOPLEFT", f, "TOPLEFT", LIST_W + 22, -top) + detail:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0) + end + UpdateFavUIRef = UpdateFavUI + + -- Bild (Kreatur: 3D-Modell mit Symbol als Rueckfall, Objekt: Symbol) + local portrait = UI.CreatePanel(detail.child, "bgDeep", "bronze") + portrait:SetSize(64, 64) + portrait:SetPoint("TOPLEFT", detail.child, "TOPLEFT", 0, 0) + portrait:Hide() + local portraitIcon = portrait:CreateTexture(nil, "ARTWORK") + portraitIcon:SetPoint("TOPLEFT", portrait, "TOPLEFT", 6, -6) + portraitIcon:SetPoint("BOTTOMRIGHT", portrait, "BOTTOMRIGHT", -6, 6) + portraitIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + -- Modell haengt am Hauptfenster (nicht am Scroll-Kind), sonst landet es an falscher Stelle + local model = CreateFrame("PlayerModel", nil, f) + model:SetAlpha(0) + model:Hide() + local function PlaceModel() + model:ClearAllPoints() + model:SetPoint("TOPLEFT", portrait, "TOPLEFT", 2, -2) + model:SetPoint("BOTTOMRIGHT", portrait, "BOTTOMRIGHT", -2, 2) + model:SetFrameLevel((detail.scroll and detail.scroll:GetFrameLevel() or 1) + 6) + local sc = detail.scroll and detail.scroll:GetVerticalScroll() or 0 + if portrait:IsShown() and model.fwNPC and sc <= 2 then + model:Show() + else + model:Hide() + end + end + if detail.scroll and detail.scroll.HookScript then + detail.scroll:HookScript("OnVerticalScroll", function() PlaceModel() end) + end + pcall(model.SetScript, model, "OnModelLoaded", function(self) + self:SetAlpha(1) + portraitIcon:Hide() + if self.fwNPC then + local ok, d = pcall(function() return self.GetDisplayInfo and self:GetDisplayInfo() end) + if ok and type(d) == "number" and Codex:SetDisplayID(self.fwNPC, d) then + if f.UpdateRowModels then f.UpdateRowModels() end + end + end + end) + + local function ShowPortrait(iconPath, npcID) + portrait:Show() + portraitIcon:SetTexture(iconPath) + portraitIcon:Show() + model:SetAlpha(0) + model.fwNPC = npcID + PlaceModel() + if npcID then + local function Check() + if model.fwNPC ~= npcID then return end + local fileID = model.GetModelFileID and model:GetModelFileID() + local ok, d = pcall(function() return model.GetDisplayInfo and model:GetDisplayInfo() end) + if model.fwShown or (type(fileID) == "number" and fileID > 0) or (ok and type(d) == "number" and d > 0) then + model:SetAlpha(1) + portraitIcon:Hide() + if ok and type(d) == "number" and Codex:SetDisplayID(npcID, d) and f.UpdateRowModels then + f.UpdateRowModels() + end + end + end + Codex.EnsureCreature(npcID, function() + if model.fwNPC ~= npcID then return end + -- ClassicAPI zieht "humanoide" Anzeigen (mit Extra-Eintrag) ueber einen Ankleide-Auftrag an. + -- Dessen Aufraeumen beim /reload stuerzt das Spiel ab. Nur Typen ohne solche Modelle zeigen. + local okI, info = pcall(function() return C_CreatureInfo.GetCreatureInfoByID(npcID) end) + local ctype = okI and type(info) == "table" and tonumber(info.type) or nil + if not (ctype and SAFE_MODEL_TYPE[ctype]) then return end + local cd = Codex.CacheDisplayID(npcID) + if cd and Codex:SetDisplayID(npcID, cd) and f.UpdateRowModels then + f.UpdateRowModels() + end + model.fwShown = nil + pcall(function() + model:ClearModel() + if cd and model.SetDisplayInfo then + model:SetDisplayInfo(cd) + model.fwShown = true + else + model:SetCreature(npcID) + end + if model.SetPortraitZoom then + model:SetPortraitZoom(1) + end + end) + Check() + C_Timer.After(0.4, Check) + C_Timer.After(0.1, function() + if model.fwNPC == npcID and model.SetPortraitZoom then pcall(model.SetPortraitZoom, model, 1) end + end) + end) + end + end + + local D = { y = 0, used = 0 } + + local function NewDetailRow() + local row = CreateFrame("Button", nil, detail.child) + row.bg = row:CreateTexture(nil, "BACKGROUND") + row.bg:SetAllPoints() + row.bg:SetColorTexture(UI.Color("hover", 0)) + row.icon = row:CreateTexture(nil, "ARTWORK") + row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + row.left = UI.CreateLabel(row, "", { size = 12 }) + row.right = UI.CreateLabel(row, "", { size = 11, color = "textDim", justify = "RIGHT" }) + row.line = row:CreateTexture(nil, "ARTWORK") + row.line:SetColorTexture(UI.Color("bronze", 0.6)) + row.line:SetHeight(1) + row:SetScript("OnEnter", function(self) + if self.clickable then + self.bg:SetColorTexture(UI.Color("hover", 0.8)) + end + if self.itemID and GameTooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + pcall(GameTooltip.SetItemByID, GameTooltip, self.itemID) + GameTooltip:Show() + elseif self.spellID and GameTooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + local ok = pcall(GameTooltip.SetSpellByID, GameTooltip, self.spellID) + if not ok then pcall(GameTooltip.SetHyperlink, GameTooltip, "spell:" .. self.spellID) end + GameTooltip:Show() + end + end) + row:SetScript("OnLeave", function(self) + self.bg:SetColorTexture(UI.Color("hover", 0)) + if GameTooltip then GameTooltip:Hide() end + end) + row:SetScript("OnClick", function(self) + if self.onClick then + self.onClick() + end + end) + return row + end + + -- Verlaufsdiagramm: history = Liste { day, avg, min }, neuester zuerst + local chart + local CHART_H = 84 + function D:Chart(history) + if not history or #history < 2 then + if chart then chart:Hide() end + return + end + if not chart then + chart = CreateFrame("Frame", nil, detail.child) + chart.segs, chart.dots = {}, {} + chart.bg = chart:CreateTexture(nil, "BACKGROUND") + chart.bg:SetAllPoints() + chart.bg:SetColorTexture(UI.Color("bgDeep", 0.6)) + chart.hi = UI.CreateLabel(chart, "", { size = 10, color = "textDim", justify = "RIGHT" }) + chart.lo = UI.CreateLabel(chart, "", { size = 10, color = "textDim", justify = "RIGHT" }) + chart.from = UI.CreateLabel(chart, "", { size = 10, color = "textDim" }) + chart.to = UI.CreateLabel(chart, "", { size = 10, color = "textDim", justify = "RIGHT" }) + end + local w = detail.child:GetWidth() + if not w or w < 120 then w = 320 end + local LABEL_W, PADX, PADY = 78, 6, 8 + local pw, ph = w - LABEL_W - PADX * 2, CHART_H - PADY * 2 - 12 + local pts = {} + local lo, hi = math.huge, 0 + for i = #history, 1, -1 do + local v = history[i].avg or history[i].min or 0 + pts[#pts + 1] = { day = history[i].day, v = v } + if v < lo then lo = v end + if v > hi then hi = v end + end + if hi == lo then lo = lo * 0.9; hi = hi * 1.1 + 1 end + local n = #pts + local firstDay, lastDay = pts[1].day, pts[n].day + local span = math.max(1, lastDay - firstDay) + for i, p in ipairs(pts) do + p.x = PADX + (p.day - firstDay) / span * pw + p.y = PADY + (1 - (p.v - lo) / (hi - lo)) * ph + end + chart:ClearAllPoints() + chart:SetPoint("TOPLEFT", detail.child, "TOPLEFT", 0, -(self.y + 6)) + chart:SetSize(w, CHART_H) + local function Tex(list, k) + local t = list[k] + if not t then + t = chart:CreateTexture(nil, "ARTWORK") + list[k] = t + end + return t + end + for i = 1, n - 1 do + local a, b = pts[i], pts[i + 1] + local dx, dy = b.x - a.x, b.y - a.y + local t = Tex(chart.segs, i) + t:SetColorTexture(1, 0.82, 0, 0.9) + t:ClearAllPoints() + t:SetSize(math.max(1, math.sqrt(dx * dx + dy * dy)), 2) + t:SetPoint("CENTER", chart, "TOPLEFT", (a.x + b.x) / 2, -(a.y + b.y) / 2) + if t.SetRotation then t:SetRotation(math.atan2 and math.atan2(-dy, dx) or math.atan(-dy, dx)) end + t:Show() + end + for i = n, #chart.segs do if chart.segs[i] then chart.segs[i]:Hide() end end + for i, p in ipairs(pts) do + local t = Tex(chart.dots, i) + t:SetColorTexture(1, 0.92, 0.4, 1) + t:SetSize(5, 5) + t:ClearAllPoints() + t:SetPoint("CENTER", chart, "TOPLEFT", p.x, -p.y) + t:Show() + end + for i = n + 1, #chart.dots do chart.dots[i]:Hide() end + chart.hi:ClearAllPoints(); chart.hi:SetPoint("TOPRIGHT", chart, "TOPRIGHT", -4, -4); chart.hi:SetWidth(LABEL_W) + chart.lo:ClearAllPoints(); chart.lo:SetPoint("BOTTOMRIGHT", chart, "BOTTOMRIGHT", -4, 16); chart.lo:SetWidth(LABEL_W) + chart.hi:SetText(FormatMoney(hi)); chart.lo:SetText(FormatMoney(lo)) + chart.from:ClearAllPoints(); chart.from:SetPoint("BOTTOMLEFT", chart, "BOTTOMLEFT", PADX, 2) + chart.to:ClearAllPoints(); chart.to:SetPoint("BOTTOMRIGHT", chart, "BOTTOMRIGHT", -LABEL_W - PADX, 2) + chart.from:SetText(string.format(L.CODEX_AH_DAYS, lastDay - firstDay)) + chart.to:SetText(L.CODEX_AH_TODAY) + chart:Show() + self.y = self.y + 6 + CHART_H + 4 + end + + function D:Reset() + if chart then chart:Hide() end + portrait:Hide() + model.fwNPC = nil + model:Hide() + for _, row in ipairs(detailRows) do + row:Hide() + end + self.y, self.used = 0, 0 + end + + -- Hoehe eines umbrechenden Textblocks (grobe Schaetzung nach Zeichenzahl und Breite) + local function TextHeight(text) + local width = detail.child and detail.child.GetWidth and detail.child:GetWidth() or 0 + if not width or width < 100 then width = detail.GetWidth and detail:GetWidth() or 0 end + if not width or width < 100 then width = 900 end + local perLine = math.max(20, math.floor((width - 40) / 6.8)) + local lines = 0 + text = (text or "") .. "\n" + for seg in text:gmatch("([^\n]*)\n") do + lines = lines + math.max(1, math.ceil(#seg / perLine)) + end + return lines * 14 + 8 + end + + -- opts: style ("title","sub","heading","line","item","link","text"), text, right, icon, itemID, onClick, indent + function D:Add(opts) + self.used = self.used + 1 + local row = detailRows[self.used] + if not row then + row = NewDetailRow() + detailRows[self.used] = row + end + local style = opts.style or "line" + local h = LINE_H + local top = 0 + if style == "title" then h = 32 elseif style == "sub" then h = 22 + elseif style == "heading" then h = 28; top = 12 + elseif style == "item" or style == "link" then h = 26 + elseif style == "text" then h = TextHeight(opts.text) end + if opts.spellID then h = 26 end + row:ClearAllPoints() + row:SetPoint("TOPLEFT", detail.child, "TOPLEFT", 0, -(self.y + top)) + row:SetPoint("TOPRIGHT", detail.child, "TOPRIGHT", 0, -(self.y + top)) + row:SetHeight(h) + self.y = self.y + top + h + + local indent = opts.indent or 0 + row.left:ClearAllPoints() + row.right:ClearAllPoints() + row.icon:Hide() + row.line:Hide() + row.clickable = opts.onClick ~= nil + row.onClick = opts.onClick + row.itemID = opts.itemID + row.spellID = opts.spellID + row:EnableMouse(row.clickable or opts.itemID ~= nil or opts.spellID ~= nil) + + local leftX = 4 + indent + if opts.icon then + row.icon:SetSize(h - 4, h - 4) + row.icon:ClearAllPoints() + row.icon:SetPoint("LEFT", row, "LEFT", leftX, 0) + row.icon:SetTexture(opts.icon) + row.icon:Show() + leftX = leftX + h + end + row.left:SetPoint("LEFT", row, "LEFT", leftX, 0) + row.right:SetPoint("RIGHT", row, "RIGHT", -6, 0) + row.left:SetPoint("RIGHT", row, "RIGHT", opts.right and -(opts.rightWidth or 150) or -6, 0) + row.left:SetWordWrap(false) + + if style == "title" then + UI.SetFont(row.left, "heading", 20) + row.left:SetTextColor(UI.Color("goldLight")) + elseif style == "sub" then + UI.SetFont(row.left, "body", 12) + row.left:SetTextColor(UI.Color("textDim")) + elseif style == "heading" then + UI.SetFont(row.left, "heading", 14) + row.left:SetTextColor(UI.Color("goldLight")) + row.line:ClearAllPoints() + row.line:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0) + row.line:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0) + row.line:Show() + else + UI.SetFont(row.left, "body", 12) + row.left:SetTextColor(UI.Color(row.clickable and "goldLight" or "text")) + end + if style == "text" then + -- umbrechender Block ueber die ganze Breite + row.left:ClearAllPoints() + row.left:SetPoint("TOPLEFT", row, "TOPLEFT", leftX, -3) + row.left:SetPoint("TOPRIGHT", row, "TOPRIGHT", -6, -3) + row.left:SetWordWrap(true) + row.left:SetJustifyH("LEFT") + row.left:SetJustifyV("TOP") + -- feste Hoehe: ohne sie schneidet der Client den Text nach den ersten Zeilen mit "..." ab + row.left:SetHeight(math.max(16, h - 4)) + end + row.left:SetText(opts.text or "") + row.right:SetText(opts.right or "") + row:Show() + return row + end + + function D:Finish() + detail:SetContentHeight(self.y + 16) + detailEmpty:SetShown(self.used == 0) + end + + -- Zeile fuer ein Item (Symbol und Name asynchron) + local function ItemRow(itemID, right, indent, extraText) + local row = D:Add({ style = "item", text = "#" .. itemID, right = right, itemID = itemID, indent = indent, + icon = "Interface\\Icons\\INV_Misc_QuestionMark", + onClick = function() Go("items", itemID) end }) + local resolved = false + Util.RequestItem(itemID, function(info) + if row.itemID ~= itemID or not info then + return + end + resolved = true + Codex.RememberQuality(itemID, info.quality) + row.icon:SetTexture(info.icon) + row.left:SetText(Colored(info.name, info.quality) .. (extraText or "")) + end) + local cached = Codex.db.names[itemID] + if cached and not resolved then + row.left:SetText(Colored(cached, Codex.QualityOf(itemID) or 1) .. (extraText or "")) + end + return row + end + + local function RefRow(ref, labelKind) + if not ref then + D:Add({ text = "-" }) + return + end + local tkind = ref.k == "npc" and "units" or ref.k == "object" and "objects" or nil + local refEnt = tkind and ref.id and Codex.V()[tkind][ref.id] + local text = (refEnt and Codex.NameOf(refEnt)) or ref.n or (ref.id and ("#" .. ref.id)) or "-" + D:Add({ style = tkind and ref.id and "link" or "line", text = text, + onClick = tkind and ref.id and function() Go(tkind, ref.id) end or nil, + right = ref.m and ref.x and string.format(FW.L.CODEX_POS, Codex.MapName(ref.m), (ref.x or 0) * 100, (ref.y or 0) * 100) or nil }) + end + + local function QuestLink(id, q, right) + local lvl = q and q.lvl + if not q and SeedOK() then + local sq = Codex.Seed.Quest(id) + lvl = sq and sq.lvl ~= 0 and sq.lvl or nil + right = right or SeedTag() + end + local title = Codex.QuestTitle(q, id) + if not q and Codex.Catalog then + local ct, cl = Codex.Catalog.Get(id) + if ct then + title = ct + lvl = lvl or cl + end + end + D:Add({ style = "link", text = title .. (lvl and (" (" .. string.format(FW.L.CODEX_LEVEL, lvl) .. ")") or ""), + right = right, onClick = function() Go("quests", id) end, icon = ICON.quests }) + end + + local function EntityLink(tkind, id, ent, right) + local lvl = ent and LevelText(ent) + if not lvl and tkind == "units" and SeedOK() then + lvl = LevelText(Codex.Seed.Entity(tkind, id) or {}) + end + D:Add({ style = "link", text = UnitTitle(ent, id, tkind) .. (lvl and (" (" .. lvl .. ")") or ""), + right = right, onClick = function() Go(tkind, id) end, icon = ICON[tkind] }) + end + + -- Verweis auf eine Kreatur / ein Objekt / ein Item aus der Vorlage + local function SeedRef(ref) + if ref.kind == "items" then + ItemRow(ref.id, SeedTag(), 0) + return + end + local m, x, y = Codex.Seed.FirstSpot(ref.kind, ref.id) + local live = Codex.V()[ref.kind][ref.id] + local lvl = ref.kind == "units" and LevelText(Codex.Seed.Entity(ref.kind, ref.id) or {}) or nil + D:Add({ style = "link", text = UnitTitle(live, ref.id, ref.kind) .. (lvl and (" (" .. lvl .. ")") or "") .. " " .. SeedTag(), + icon = ICON[ref.kind], onClick = function() Go(ref.kind, ref.id) end, + right = m and string.format(FW.L.CODEX_POS, Codex.MapName(m), x * 100, y * 100) or nil }) + end + + ---------------- Details je Art ---------------- + local function LootSection(ent, alt) + local list = Codex.DropList(ent, alt) + if #list == 0 then + return + end + D:Add({ style = "heading", text = alt and L.CODEX_SEC_ALT or L.CODEX_SEC_LOOT }) + local k = list[1].k + D:Add({ style = "sub", text = string.format(L.CODEX_KILLS, k) .. ((k < 10) and (" | " .. L.CODEX_LOW_SAMPLE) or "") }) + for _, e in ipairs(list) do + local extra = e.qi and (" |cff9d9d9d[" .. L.CODEX_QUEST_ITEM .. "]|r") or "" + local ahp = Codex.AuctionProvider and Codex.AuctionPrice(e.id) + if ahp and ahp > 0 then + extra = extra .. " |cffffd100" .. L.CODEX_AH_SHORT .. " " .. FormatMoney(ahp) .. "|r" + end + local right = string.format(L.CODEX_DROP_CHANCE, FormatChance(e.chance), e.n, e.k) + if e.k < 10 then right = "|cff9d9d9d~ " .. right .. "|r" end + ItemRow(e.id, right, 0, extra) + end + end + + local detailMissing = false + local function ValueSection(ent) + local total, coin, items, missing = Codex.KillValue(ent) + detailMissing = false + if not total then + return + end + D:Add({ style = "heading", text = L.CODEX_VALUE_HEAD }) + D:Add({ text = L.CODEX_VALUE_COIN, right = FormatMoney(coin) }) + D:Add({ text = L.CODEX_VALUE_ITEMS, right = FormatMoney(items) }) + D:Add({ text = L.CODEX_VALUE_TOTAL, right = FormatMoney(total) }) + if Codex.AuctionProvider then + local ahTotal, _, ahItems, ahMissing = Codex.KillValue(ent, true) + if ahTotal then + D:Add({ text = L.CODEX_VALUE_ITEMS_AH, right = FormatMoney(ahItems) }) + D:Add({ text = L.CODEX_VALUE_TOTAL_AH, right = FormatMoney(ahTotal) }) + if ahMissing > 0 then + D:Add({ style = "sub", text = string.format(L.CODEX_VALUE_MISSING, ahMissing) }) + end + end + end + if missing > 0 then + detailMissing = true + D:Add({ style = "sub", text = string.format(L.CODEX_VALUE_MISSING, missing) }) + end + end + + local function QuestsOfUnit(tkind, id, uname) + local gives, turns, targets = {}, {}, {} + local refKind = tkind == "units" and "npc" or "object" + if SeedOK() and Codex.RepairObjectives then Codex.RepairObjectives() end + local lname = uname and uname:lower() or nil + for qid, q in pairs(Codex.V().quests) do + if q.give and q.give.k == refKind and q.give.id == id then gives[#gives + 1] = qid end + if q.turn and q.turn.k == refKind and q.turn.id == id then turns[#turns + 1] = qid end + if tkind == "units" then + for _, o in ipairs(q.obj or {}) do + if (o.ids and o.ids[id]) or (lname and o.ty == "monster" and o.name and o.name:lower() == lname) then + targets[#targets + 1] = qid + break + end + end + end + end + if SeedOK() then + -- Vorlage: Quests, die wir noch nicht mit diesem Geber / Abgabeort / Ziel kennen + local sg, st, sx = Codex.Seed.QuestsOf(tkind, id) + for _, def in ipairs({ { gives, sg }, { turns, st }, { targets, sx } }) do + local have = {} + for _, qid in ipairs(def[1]) do have[qid] = true end + for _, qid in ipairs(def[2]) do + if not have[qid] then + have[qid] = true + def[1][#def[1] + 1] = qid + end + end + end + end + if Codex.Catalog and Codex.Catalog.Reverse then + local list = Codex.Catalog.Reverse()[tkind == "units" and "monster" or "object"][id] + if list then + local have = {} + for _, qid in ipairs(targets) do have[qid] = true end + for _, qid in ipairs(list) do + if not have[qid] then + have[qid] = true + targets[#targets + 1] = qid + end + end + end + end + local function QInfo(qid) + local q = Codex.V().quests[qid] + local lvl = q and q.lvl + if not lvl and SeedOK() then + local sq = Codex.Seed.Quest(qid) + lvl = sq and sq.lvl + end + local title = Codex.QuestTitle(q, qid) + if not q and Codex.Catalog then + local ct, cl = Codex.Catalog.Get(qid) + if ct then + title = ct + lvl = lvl or cl + end + end + return lvl or 0, title + end + local function Sorted(list) + table.sort(list, function(a, b) + local la, ta = QInfo(a) + local lb, tb = QInfo(b) + if la ~= lb then return la < lb end + if ta ~= tb then return ta < tb end + return a < b + end) + return list + end + -- von Hand zugeordnet (Properties) und gelernte Quest-Item-Quellen + local manualKill, manualItems = Codex.ManualQuestLinks(tkind, id) + do + local have = {} + for _, qid in ipairs(targets) do have[qid] = true end + for _, qid in ipairs(manualKill) do + if not have[qid] then have[qid] = true targets[#targets + 1] = qid end + end + end + local items, haveItem = {}, {} + local srcKey = (tkind == "units" and "u" or "o") .. id + for qid, q in pairs(Codex.V().quests) do + for itemID, srcs in pairs(q.qi or {}) do + if srcs[srcKey] and not haveItem[qid .. ":" .. itemID] then + haveItem[qid .. ":" .. itemID] = true + items[#items + 1] = { qid = qid, item = itemID } + end + end + end + for _, s in ipairs(manualItems) do + local qs, is = tostring(s):match("^(%d+):(%d+)$") + local qid, itemID = tonumber(qs), tonumber(is) + if qid and itemID and not haveItem[qid .. ":" .. itemID] then + haveItem[qid .. ":" .. itemID] = true + items[#items + 1] = { qid = qid, item = itemID, manual = true } + end + end + if #items > 0 then + table.sort(items, function(a, b) if a.qid ~= b.qid then return a.qid < b.qid end return a.item < b.item end) + D:Add({ style = "heading", text = L.CODEX_SEC_QITEMS }) + for i, e in ipairs(items) do + if i > 30 then break end + QuestLink(e.qid, Codex.V().quests[e.qid], (Codex:ItemName(e.item) or ("#" .. e.item)) .. (e.manual and (" [" .. L.CODEX_MANUAL_TAG .. "]") or "")) + end + end + local belongs = Codex.EntityQuests(tkind == "units" and "u" or "o", id) + if #belongs > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_QBEL }) + for i, qid in ipairs(belongs) do + if i > 30 then break end + QuestLink(qid, Codex.V().quests[qid], L.CODEX_MANUAL_TAG) + end + end + for _, def in ipairs({ { gives, L.CODEX_SEC_GIVES }, { turns, L.CODEX_SEC_TURNS }, { targets, L.CODEX_SEC_TARGET } }) do + if #def[1] > 0 then + D:Add({ style = "heading", text = def[2] }) + for i, qid in ipairs(Sorted(def[1])) do + if i > 30 then break end + QuestLink(qid, Codex.V().quests[qid]) + end + end + end + end + + local MAP_ICON = "Interface\\Icons\\INV_Misc_Map_01" + + local function MapLink(spec, label) + D:Add({ style = "link", text = L.CODEX_MAP_SHOW, icon = MAP_ICON, + onClick = function() + Codex.Map:Show(Codex.CollectPoints(spec), label) + end }) + end + + local function SpotRows(cells, limit, noHits) + for _, s in ipairs(Codex.TopSpots(cells, limit or 8)) do + D:Add({ text = string.format(L.CODEX_POS, Codex.MapName(s.m), s.x * 100, s.y * 100), + right = (not noHits) and string.format(L.CODEX_SPOT_HITS, s.n) or nil }) + end + end + + -- Fundorte: eigene Beobachtungen, sonst die Vorlage + local function PlacesSection(ent, spec, label, seedCells) + if ent and ent.cells and next(ent.cells) ~= nil then + D:Add({ style = "heading", text = L.CODEX_SEC_SPOTS }) + SpotRows(ent.cells, 8) + elseif seedCells and next(seedCells) ~= nil then + D:Add({ style = "heading", text = L.CODEX_SEC_SPOTS .. " " .. SeedTag() }) + SpotRows(seedCells, 8, true) + else + return + end + if spec then + MapLink(spec, label) + end + end + + local function ShowQuest(id) + local live = Codex.V().quests[id] + local sq = SeedOK() and Codex.Seed.Quest(id) or nil + local catTitle, catLvl + if not live and not sq and Codex.Catalog then catTitle, catLvl = Codex.Catalog.Get(id) end + if not live and not sq and not catTitle then return end + local q = live or {} + D:Add({ style = "title", text = catTitle or Codex.QuestTitle(live, id) }) + local sub = {} + local lvl = q.lvl or (sq and sq.lvl and sq.lvl > 0 and sq.lvl) or catLvl or nil + if lvl then sub[#sub + 1] = string.format(L.CODEX_LEVEL, lvl) end + if sq and sq.min and sq.min > 0 then sub[#sub + 1] = string.format(L.CODEX_MINLEVEL, sq.min) end + if q.zone then + sub[#sub + 1] = Codex.MapName(q.zone) + elseif sq then + local ref = sq.start[1] or sq.stop[1] + local m = ref and ref.kind ~= "items" and Codex.Seed.MapOf(ref.kind, ref.id) + if m then sub[#sub + 1] = Codex.MapName(m) end + end + local wm = Codex.Catalog and Codex.Catalog.QuestMeta and Codex.Catalog.QuestMeta(id) + if wm then + if wm.zone and not q.zone and not (sq and sq.start[1]) then sub[#sub + 1] = wm.zone end + if wm.info then sub[#sub + 1] = wm.info end + end + if q.dg ~= nil then sub[#sub + 1] = q.dg and L.CODEX_PROPS_DG_YES or L.CODEX_PROPS_DG_NO end + if (q.seen or 0) > 0 then sub[#sub + 1] = string.format(L.CODEX_SEEN, q.seen) end + if (q.done or 0) > 0 then sub[#sub + 1] = string.format(L.CODEX_DONE, q.done) end + local det0 = Codex.Catalog and Codex.Catalog.Details and Codex.Catalog.Details(id) + if det0 and det0.qtype then sub[#sub + 1] = det0.qtype end + sub[#sub + 1] = "ID " .. id + local startOnly = live and Codex.Share and Codex.Share.Origin and Codex.Share.Origin("q", id) == "start" + if not live then sub[#sub + 1] = catTitle and ("|cff8a8a96[" .. L.CODEX_CAT_TAG .. "]|r") or SeedTag() + elseif startOnly then sub[#sub + 1] = "|cff8a8a96[" .. L.CODEX_START_TAG .. "]|r" end + D:Add({ style = "sub", text = table.concat(sub, " | ") }) + if not live then + D:Add({ style = "sub", text = catTitle and L.CODEX_CAT_NOTE or L.CODEX_SEED_NOTE }) + elseif startOnly then + D:Add({ style = "sub", text = L.CODEX_START_NOTE }) + end + local status = Codex.QuestStatus(id) + D:Add({ style = "sub", text = string.format(L.CODEX_STATUS_LINE, + status == "done" and L.CODEX_STATUS_DONE or status == "active" and L.CODEX_STATUS_ACTIVE or L.CODEX_STATUS_OPEN) }) + if q.rem then + D:Add({ style = "sub", text = L.CODEX_REMOTE_ONLY }) + end + if q.partial then + D:Add({ style = "sub", text = L.CODEX_PARTIAL }) + end + + if q.tm and q.tm.n and q.tm.n > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_TIME }) + D:Add({ text = L.CODEX_QT_EFF, right = FormatDuration(q.tm.sum / q.tm.n / 10) }) + if q.tr and q.tr.n and q.tr.n > 0 then + D:Add({ text = L.CODEX_QT_RAW, right = FormatDuration(q.tr.sum / q.tr.n / 10) }) + end + if q.ta and q.ta.n and q.ta.n > 0 then + D:Add({ text = L.CODEX_QT_ALL, right = FormatDuration(q.ta.sum / q.ta.n / 10) }) + end + D:Add({ text = L.CODEX_QT_N, right = tostring(q.tm.n) }) + end + + D:Add({ style = "heading", text = L.CODEX_SEC_GIVE }) + if q.si then + -- Quest startet durch ein Item: das Item (mit seinen Fundorten im Item-Eintrag) statt einer Spielerposition + D:Add({ style = "sub", text = L.CODEX_GIVE_BY_ITEM }) + ItemRow(q.si, nil, 0, " |cff9d9d9d[" .. L.CODEX_START_ITEM .. "]|r") + elseif q.give or not (sq and #sq.start > 0) then + RefRow(q.give) + else + for _, r in ipairs(sq.start) do SeedRef(r) end + end + D:Add({ style = "heading", text = L.CODEX_SEC_TURN }) + if q.turn or not (sq and #sq.stop > 0) then + RefRow(q.turn) + else + for _, r in ipairs(sq.stop) do SeedRef(r) end + end + if #Codex.CollectPoints({ kind = "quests", id = id }) > 0 then + MapLink({ kind = "quests", id = id }, Codex.QuestTitle(q, id)) + end + + if q.si then + D:Add({ style = "sub", text = L.CODEX_PROPS_STARTITEM .. " " .. tostring(q.si) .. " " .. tostring(Codex.ItemName and Codex:ItemName(q.si) or "") }) + end + if q.dtx then + D:Add({ style = "heading", text = L.CODEX_SEC_QTEXT }) + D:Add({ style = "sub", text = q.dtx }) + end + + local hasReward = q.xp or q.money or q.rep or q.fixed or q.choice + if hasReward then + D:Add({ style = "heading", text = L.CODEX_SEC_REWARD }) + if q.xp then D:Add({ text = L.CODEX_XP, right = tostring(q.xp) }) end + if q.money then + D:Add({ text = L.CODEX_MONEY, right = FormatMoney(q.money) }) + end + if q.rep then + local names = {} + for name in pairs(q.rep) do names[#names + 1] = name end + table.sort(names) + for _, name in ipairs(names) do + local v = q.rep[name] + D:Add({ text = L.CODEX_REP .. ": " .. name, right = (v > 0 and "+" or "") .. v }) + end + end + for _, r in ipairs(q.fixed or {}) do + ItemRow(r.id, r.c and r.c > 1 and ("x" .. r.c) or nil, 0, " |cff9d9d9d[" .. L.CODEX_FIXED .. "]|r") + end + for _, r in ipairs(q.choice or {}) do + local chosen = q.chosen and q.chosen[r.id] + ItemRow(r.id, chosen and string.format(L.CODEX_CHOSEN, chosen) or (r.c and r.c > 1 and ("x" .. r.c) or nil), 0, + " |cff9d9d9d[" .. L.CODEX_CHOICE .. "]|r") + end + end + + if q.obj and #q.obj > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_OBJ }) + for _, o in ipairs(q.obj) do + local label = L["CODEX_TYPE_" .. tostring(o.ty)] or L.CODEX_TYPE_other + local itemIDs = o.ty == "item" and Codex.ObjectiveItems(q, o, id) or nil + local shownName = o.name + if not shownName and itemIDs and itemIDs[1] then + shownName = Codex:ItemName(itemIDs[1]) + end + D:Add({ text = label .. ": " .. (shownName or o.txt or "?"), right = o.req and ("x" .. o.req) or nil }) + local lname = o.name and o.name:lower() or nil + if o.ty == "monster" then + local shown = {} + local ids = {} + for uid, hits in pairs(o.ids or {}) do ids[#ids + 1] = { id = uid, n = hits } end + table.sort(ids, function(a, b) return a.n > b.n end) + for _, e in ipairs(ids) do + shown[e.id] = true + EntityLink("units", e.id, Codex.V().units[e.id], nil) + end + for key in pairs(Codex.QuestKillKeys(id)) do + local sid = tonumber(key:sub(2)) + if key:sub(1, 1) == "u" and sid and not shown[sid] then + shown[sid] = true + EntityLink("units", sid, Codex.V().units[sid], L.CODEX_MANUAL_TAG) + end + end + if lname then + local count = 0 + for uid, u in pairs(Codex.V().units) do + if not shown[uid] and ((u.n and u.n:lower() == lname) or (u.cde and u.cde:lower() == lname) or (u.cen and u.cen:lower() == lname)) then + count = count + 1 + if count > 4 then break end + EntityLink("units", uid, u, nil) + end + end + end + elseif itemIDs then + for _, itemID in ipairs(itemIDs) do + for key in pairs(Codex.QuestItemKeys(id, itemID)) do + local tk = key:sub(1, 1) == "u" and "units" or "objects" + local sid = tonumber(key:sub(2)) + local ent = tk == "units" and Codex.V().units[sid] or Codex.V().objects[sid] + EntityLink(tk, sid, ent, L.CODEX_MANUAL_TAG) + end + local srcs = q.qi and q.qi[itemID] + if srcs then + local list = {} + for key, n in pairs(srcs) do list[#list + 1] = { key = key, n = n } end + table.sort(list, function(a, b) return a.n > b.n end) + for i, e in ipairs(list) do + if i > 6 then break end + local tk = e.key:sub(1, 1) == "u" and "units" or "objects" + local sid = tonumber(e.key:sub(2)) + local ent = tk == "units" and Codex.V().units[sid] or Codex.V().objects[sid] + EntityLink(tk, sid, ent, tostring(e.n) .. "x") + end + end + end + end + end + elseif sq and #sq.obj > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_OBJ .. " " .. SeedTag() }) + for _, r in ipairs(sq.obj) do SeedRef(r) end + end + + -- Gehoert zur Quest (von Hand zugeordnet): Items, Kreaturen, Objekte + do + local rel = Codex.QuestRelated(id) + if #rel.i + #rel.u + #rel.o > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_QREL }) + for _, itemID in ipairs(rel.i) do ItemRow(itemID, L.CODEX_MANUAL_TAG, 0) end + for _, uid in ipairs(rel.u) do EntityLink("units", uid, Codex.V().units[uid], L.CODEX_MANUAL_TAG) end + for _, oid in ipairs(rel.o) do EntityLink("objects", oid, Codex.V().objects[oid], L.CODEX_MANUAL_TAG) end + end + end + + -- Quest-Text (Beschreibung, Ziel, Zwischentext, Abschlusstext); $N wird zum eigenen Namen + do + local qv = Codex.V().quests[id] + local tx = qv and qv.tx + if type(tx) == "table" then + local myName = (UnitName and UnitName("player")) or "?" + local first = true + for _, f in ipairs({ "d", "o", "p", "r" }) do + local v = tx[f] + if type(v) == "string" and v ~= "" then + if first then + D:Add({ style = "heading", text = L.CODEX_SEC_QTEXT }) + first = false + end + D:Add({ style = "sub", text = L["CODEX_QT_" .. f:upper()] }) + local shown = v:gsub("{br}", "\n\n"):gsub("%$[nN]", (myName:gsub("%%", "%%%%"))) + D:Add({ style = "text", text = shown }) + end + end + end + end + + -- Ruf-Ziel aus dem Quest-Cache + do + local m = Codex.Catalog and Codex.Catalog.QuestMeta and Codex.Catalog.QuestMeta(id) + if m and m.rep then + D:Add({ style = "heading", text = L.CODEX_SEC_REP .. " |cff8a8a96[" .. L.CODEX_CAT_TAG .. "]|r" }) + D:Add({ text = m.rep }) + end + end + + -- Laut Server (Katalog): Ziele, Belohnung und Texte aus der Server-Antwort; nur, was die eigenen Daten nicht haben + do + local det = Codex.Catalog and Codex.Catalog.Details and Codex.Catalog.Details(id) + if det then + local qv = Codex.V().quests[id] + local tag = " |cff8a8a96[" .. L.CODEX_CAT_TAG .. "]|r" + if #det.req > 0 and not (qv and qv.obj and #qv.obj > 0) then + D:Add({ style = "heading", text = L.CODEX_SEC_OBJ .. tag }) + for _, r in ipairs(det.req) do + local cnt = r.count and r.count > 1 and ("x" .. r.count) or nil + if r.kind == "monster" then + ServerInfo("units", r.id, true) + EntityLink("units", r.id, Codex.V().units[r.id], cnt) + elseif r.kind == "object" then + ServerInfo("objects", r.id, true) + EntityLink("objects", r.id, Codex.V().objects[r.id], cnt) + else + ItemRow(r.id, cnt, 0) + end + end + end + local hasRew = (det.money or 0) > 0 or #det.rew > 0 or #det.choice > 0 or det.spell + if hasRew and not (qv and (qv.fixed or qv.choice or qv.money)) then + D:Add({ style = "heading", text = L.CODEX_SEC_REWARD .. tag }) + if (det.money or 0) > 0 then D:Add({ text = L.CODEX_MONEY, right = FormatMoney(det.money) }) end + for _, it in ipairs(det.rew) do + ItemRow(it.id, it.count > 1 and ("x" .. it.count) or nil, 0, " |cff9d9d9d[" .. L.CODEX_FIXED .. "]|r") + end + for _, it in ipairs(det.choice) do + ItemRow(it.id, it.count > 1 and ("x" .. it.count) or nil, 0, " |cff9d9d9d[" .. L.CODEX_CHOICE .. "]|r") + end + if det.spell then D:Add({ text = L.CODEX_SRV_SPELL, right = tostring(det.spell) }) end + end + if det.src and not (qv and qv.si) then + D:Add({ style = "heading", text = L.CODEX_PROPS_STARTITEM .. tag }) + ItemRow(det.src, nil, 0) + end + if not (qv and type(qv.tx) == "table") and (det.desc or det.obj or det.done) then + local myName = (UnitName and UnitName("player")) or "?" + local function Fill(v) + v = v:gsub("%$[bB]", "\n"):gsub("%$[nN]", (myName:gsub("%%", "%%%%"))) + return v + end + D:Add({ style = "heading", text = L.CODEX_SEC_QTEXT .. tag }) + for _, def in ipairs({ { "D", det.desc }, { "O", det.obj }, { "R", det.done } }) do + if def[2] and def[2] ~= "" then + D:Add({ style = "sub", text = L["CODEX_QT_" .. def[1]] }) + D:Add({ style = "text", text = Fill(def[2]) }) + end + end + end + end + end + + -- Questkette: Vorquests und Folgequests (Daten und Vorlage) + do + local function Chain(heading, ids) + if #ids == 0 then return end + D:Add({ style = "heading", text = heading }) + for i, qid in ipairs(ids) do + if i > 30 then break end + local lq = Codex.V().quests[qid] + QuestLink(qid, lq, lq and (lq.done or 0) > 0 and string.format(L.CODEX_DONE, lq.done) or nil) + end + end + Chain(L.CODEX_SEC_PRE, Codex.QuestPre(id)) + Chain(L.CODEX_SEC_NEXT, Codex.QuestNext(id)) + end + end + + local function XPSection(ent) + if not ent.xp or next(ent.xp) == nil then + return + end + D:Add({ style = "heading", text = L.CODEX_SEC_XP }) + local levels = {} + for pl in pairs(ent.xp) do levels[#levels + 1] = pl end + table.sort(levels, function(a, b) return a > b end) + local rows = 0 + for _, pl in ipairs(levels) do + local xm = ent.xm and ent.xm[pl] + if xm and next(xm) then + -- nach Gegnerstufe getrennt: zu jeder Zeile gehoert ein Formelwert + local mls = {} + for ml in pairs(xm) do mls[#mls + 1] = ml end + table.sort(mls, function(a, b) return a > b end) + for _, ml in ipairs(mls) do + if rows >= 12 then break end + rows = rows + 1 + local s = xm[ml] + local pred = Codex.PredictXP(pl, ml, ent.c) + D:Add({ + text = string.format(L.CODEX_XP_UNIT_ROW2, pl, ml), + right = string.format("Ø %.1f (n=%d)", s.sum / s.n, s.n) .. " | " .. pred, + }) + local rs = ent.xr and ent.xr[pl] and ent.xr[pl][ml] + if rs and rs.n and rs.n > 0 and rows < 12 then + rows = rows + 1 + D:Add({ + style = "sub", + text = " " .. L.CODEX_XP_WITH_RESTED, + right = string.format("Ø %.1f (n=%d)", rs.sum / rs.n, rs.n), + }) + end + end + elseif rows < 12 then + rows = rows + 1 + local s = ent.xp[pl] + local right = string.format("Ø %.1f (n=%d)", s.sum / s.n, s.n) + if ent.l1 and ent.l1 == ent.l2 then + right = right .. " | " .. Codex.PredictXP(pl, ent.l1, ent.c) + end + D:Add({ text = string.format(L.CODEX_XP_UNIT_ROW, pl), right = right }) + end + end + end + + -- Beute und Waren laut Vorlage; was wir selbst gesehen haben, steht schon oben und wird hier uebersprungen + local function SeedLootSection(tkind, id, ent) + local shown = {} + for _, e in ipairs(Codex.Seed.Drops(tkind, id)) do + local seen = (ent and ent.d and ent.d[e.item]) or (ent and ent.alt and ent.alt.d and ent.alt.d[e.item]) + if not seen then shown[#shown + 1] = e end + end + if #shown > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_LOOT_SEED }) + for i, e in ipairs(shown) do + if i > 40 then break end + ItemRow(e.item, SeedPct(e.chance), 0) + end + end + local roles = Codex.Seed.Roles(tkind, id) + if tkind == "units" and roles and roles.vendor then + local goods = Codex.Seed.VendorItems(id) + if #goods > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_SELLS }) + local shownWares = 0 + for _, itemID in ipairs(goods) do + if not (ent and ent.wa and ent.wa[itemID]) then + shownWares = shownWares + 1 + if shownWares > 60 then break end + ItemRow(itemID, nil, 0) + end + end + end + end + end + + -- Rollen laut eigenen und geteilten Beobachtungen: Waren mit allen Preisen, Lehrstoff, Flugziele + local function RolesSection(tkind, ent) + if tkind ~= "units" then return end + if ent.ti then + D:Add({ style = "sub", text = string.format(L.CODEX_TITLE_LINE, ent.ti) }) + end + if ent.wa and next(ent.wa) then + local goods = {} + for itemID, w in pairs(ent.wa) do goods[#goods + 1] = { id = itemID, w = w } end + table.sort(goods, function(a, b) return a.id < b.id end) + D:Add({ style = "heading", text = string.format(L.CODEX_SEC_WARES, #goods) }) + for i, g in ipairs(goods) do + if i > 120 then break end + local extra = {} + if g.w.st and g.w.st > 1 then extra[#extra + 1] = "x" .. g.w.st end + if g.w.lim then extra[#extra + 1] = L.CODEX_WARE_LIMITED end + ItemRow(g.id, PriceText(g.w.pr, g.w.ec), 0, #extra > 0 and (" |cff9a9aa3" .. table.concat(extra, " ") .. "|r") or nil) + end + end + if ent.tr and next(ent.tr) then + local list = {} + for _, r in pairs(ent.tr) do list[#list + 1] = r end + table.sort(list, function(a, b) + if (a.lv or 0) ~= (b.lv or 0) then return (a.lv or 0) < (b.lv or 0) end + return (a.nm or "") < (b.nm or "") + end) + D:Add({ style = "heading", text = string.format(L.CODEX_SEC_TRAINS, #list) }) + for i, r in ipairs(list) do + if i > 150 then break end + local right = {} + if r.lv then right[#right + 1] = string.format(L.CODEX_TRAIN_LEVEL, r.lv) end + if r.sn then right[#right + 1] = r.sr and string.format("%s %d", r.sn, r.sr) or r.sn end + local price = PriceText(r.pr) + if price then right[#right + 1] = price end + local rank = r.rk or (r.sp and Codex.SpellRank(r.sp)) or nil + local icon = r.sp and Codex.SpellIcon(r.sp) or nil + D:Add({ text = (r.nm or "?") .. (rank and (" |cff9a9aa3(" .. rank .. ")|r") or ""), + right = table.concat(right, " | "), rightWidth = 330, + spellID = r.sp, icon = icon or (r.sp and "Interface\\Icons\\INV_Misc_QuestionMark") or nil }) + end + end + if ent.fl and next(ent.fl) then + local list = {} + for dest, pr in pairs(ent.fl) do list[#list + 1] = { dest = dest, pr = pr } end + table.sort(list, function(a, b) return a.dest < b.dest end) + D:Add({ style = "heading", text = string.format(L.CODEX_SEC_FLIGHTS, #list) }) + if ent.fn then D:Add({ style = "sub", text = string.format(L.CODEX_FLIGHT_FROM, ent.fn) }) end + for _, e in ipairs(list) do + D:Add({ text = e.dest, right = PriceText(e.pr) }) + end + end + end + + local function ShowEntity(tkind, id) + local live = tkind == "units" and Codex.V().units[id] or Codex.V().objects[id] + local seedEnt = SeedOK() and Codex.Seed.Entity(tkind, id) or nil + local srv = ServerInfo(tkind, id, true) + local catRev = Codex.Catalog and Codex.Catalog.Reverse and Codex.Catalog.Reverse()[tkind == "units" and "monster" or "object"][id] + if not live and not seedEnt and not srv and not catRev then return end + local ent = live or {} + ShowPortrait(tkind == "units" and UnitIcon(ent) or ICON.objects, tkind == "units" and id or nil) + D:Add({ style = "title", text = UnitTitle(ent, id, tkind), indent = 76 }) + local sub = {} + local lvl = LevelText(ent) or (seedEnt and tkind == "units" and LevelText(seedEnt)) or nil + if lvl then sub[#sub + 1] = lvl end + if ent.c then + sub[#sub + 1] = (ent.ed and ent.ed.cl and L["CODEX_PROPS_CLASS_" .. ent.ed.cl]) or ent.c + elseif ent.c == false then + sub[#sub + 1] = L.CODEX_PROPS_CLASS_normal + elseif seedEnt and tkind == "units" and RankLabel(seedEnt.rank) then + sub[#sub + 1] = RankLabel(seedEnt.rank) + end + if ent.dg ~= nil then + sub[#sub + 1] = ent.dg and L.CODEX_PROPS_DG_YES or L.CODEX_PROPS_DG_NO + elseif ent.iz and next(ent.iz) then + sub[#sub + 1] = L.CODEX_PROPS_DG_YES + end + if not (ent.ez and #ent.ez > 0) and ent.iz and next(ent.iz) then + local names = {} + for iid in pairs(ent.iz) do names[#names + 1] = Codex.InstanceName(iid) end + table.sort(names) + sub[#sub + 1] = string.format(L.CODEX_PROPS_ZONES_LINE, table.concat(names, ", ")) + end + if ent.ez and #ent.ez > 0 then + local names = {} + for _, m in ipairs(ent.ez) do names[#names + 1] = Codex.MapName(m) end + sub[#sub + 1] = string.format(L.CODEX_PROPS_ZONES_LINE, table.concat(names, ", ")) + end + if ent.f then sub[#sub + 1] = string.format(L.CODEX_FAMILY, ent.f) end + if seedEnt and (seedEnt.fac == "A" or seedEnt.fac == "H") then + sub[#sub + 1] = seedEnt.fac == "A" and L.CODEX_FAC_A or L.CODEX_FAC_H + end + if srv then + if tkind == "units" then + local CC = _G.C_CreatureInfo + local okT, ti = pcall(CC and CC.GetCreatureTypeInfo or function() end, srv.type) + if okT and type(ti) == "table" and type(ti.name) == "string" then sub[#sub + 1] = ti.name end + if not ent.c and RankLabel(srv.rank) then sub[#sub + 1] = RankLabel(srv.rank) end + if type(srv.subName) == "string" and srv.subName ~= "" then sub[#sub + 1] = "<" .. srv.subName .. ">" end + else + local g = GO_TYPES[srv.type] + if g then sub[#sub + 1] = Codex.Lang() == "en" and g[1] or g[2] end + end + end + local roleNames = RoleNames(tkind, id) + if #roleNames > 0 then sub[#sub + 1] = table.concat(roleNames, ", ") end + sub[#sub + 1] = "ID " .. id + local startOnly = live and Codex.Share and Codex.Share.Origin and Codex.Share.Origin(tkind == "units" and "u" or "o", id) == "start" + if not live then sub[#sub + 1] = seedEnt and SeedTag() or ("|cff8a8a96[" .. L.CODEX_CAT_TAG .. "]|r") + elseif startOnly then sub[#sub + 1] = "|cff8a8a96[" .. L.CODEX_START_TAG .. "]|r" end + D:Add({ style = "sub", text = table.concat(sub, " | "), indent = 76 }) + D.y = math.max(D.y, 72) + if not live then + D:Add({ style = "sub", text = seedEnt and L.CODEX_SEED_NOTE or L.CODEX_SRV_NOTE }) + elseif startOnly then + D:Add({ style = "sub", text = L.CODEX_START_NOTE }) + end + -- Gebiet mit Stufenspanne (Verweis in die Gebietsansicht) + for i, zkey in ipairs(Codex.ZonesOf(ent)) do local zkey = zkey + if i > 3 then break end + local zd = Codex.Zones and Codex.Zones.Get(zkey) + D:Add({ style = "link", text = Codex.MapName(zkey), icon = ICON.zones, + right = zd and RangeText(zd.lo, zd.hi) or nil, onClick = function() Go("zones", zkey) end }) + end + local ttkClasses = Codex.Time and Codex.Time.UnitClasses(ent) or {} + if #ttkClasses > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_TTK_CLASSES }) + for _, c in ipairs(ttkClasses) do + D:Add({ + text = Codex.Time.ClassLabel(c.cls) .. (c.own and (" " .. L.CODEX_TTK_YOU) or ""), + right = string.format("%.1f s (n=%d)", c.secs, c.n), + }) + end + end + if ent.rk and ent.rk > 0 then + D:Add({ style = "sub", text = string.format(L.CODEX_REMOTE_HINT, ent.rk, ent.k or 0) }) + elseif ent.remote and ent.remote > 0 then + D:Add({ style = "sub", text = L.CODEX_REMOTE_ONLY }) + end + local avg = Codex.AverageCoin(ent) + if avg then + D:Add({ text = string.format(L.CODEX_COIN_AVG, ""), right = FormatMoney(avg) }) + end + PlacesSection(ent, { kind = tkind, id = id }, UnitTitle(ent, id, tkind), seedEnt and Codex.Seed.Cells(tkind, id) or nil) + RolesSection(tkind, ent) + XPSection(ent) + ValueSection(ent) + QuestsOfUnit(tkind, id, UnitTitle(ent, id, tkind)) + LootSection(ent, false) + if ent.alt then LootSection(ent, true) end + if seedEnt then SeedLootSection(tkind, id, live) end + end + + local function ShowZone(key) + local zd = Codex.Zones.Get(key) + if not zd then return end + ShowPortrait(ICON.zones, nil) + D:Add({ style = "title", text = Codex.MapName(key), indent = 76 }) + local sub = { zd.dungeon and L.CODEX_ZONE_DUNGEON or L.CODEX_ZONE_WORLD, (key < 0 and "Instanz " or "Karte ") .. math.abs(key) } + D:Add({ style = "sub", text = table.concat(sub, " | "), indent = 76 }) + D.y = math.max(D.y, 72) + D:Add({ style = "heading", text = L.CODEX_SEC_ZONE_LEVELS }) + D:Add({ text = L.CODEX_ZONE_TYPICAL, right = RangeText(zd.lo, zd.hi) or "-" }) + if zd.avg then D:Add({ text = L.CODEX_ZONE_AVG, right = string.format("%.1f", zd.avg) }) end + D:Add({ text = L.CODEX_ZONE_COUNT, right = tostring(zd.units) }) + if zd.manual then D:Add({ style = "sub", text = L.CODEX_ZONE_MANUAL_NOTE }) end + if zd.few and not zd.manual then D:Add({ style = "sub", text = L.CODEX_ZONE_FEW_NOTE }) end + if #zd.out > 0 then + local parts = {} + for _, e in ipairs(zd.out) do + local ent = Codex.V().units[e.id] + parts[#parts + 1] = string.format("%s (%s)", UnitTitle(ent, e.id, "units"), RangeText(e.l1, e.l2) or "?") + if #parts >= 6 then break end + end + D:Add({ style = "sub", text = L.CODEX_ZONE_OUTLIER_LIST .. ": " .. table.concat(parts, ", ") }) + end + -- eigene Messung (Modul Quest): Erfahrung pro Stunde in diesem Gebiet, nur fuer die aktuellen Einstellungen + local rates = Codex.ZoneRateProvider and Codex.ZoneRateProvider(key) or {} + if #rates > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_ZONE_RATE }) + D:Add({ style = "sub", text = L.CODEX_ZONE_RATE_NOTE }) + for _, r in ipairs(rates) do + local mins = r.hours and (r.hours * 60) or nil + D:Add({ text = string.format(L.CODEX_ZONE_RATE_LINE, r.pl), right = string.format("%d XP/h%s (%d min)", + r.rate, mins and string.format(L.CODEX_ZONE_RATE_LEVEL, mins) or "", r.secs / 60) }) + end + end + -- Kreaturen des Gebiets, nach Stufe + local members = Codex.Zones.Members(key) + D:Add({ style = "heading", text = string.format(L.CODEX_SEC_ZONE_UNITS, #members) }) + for i, m in ipairs(members) do local m = m + if i > 150 then break end + local ent = Codex.V().units[m.id] + local tags = {} + if m.c then tags[#tags + 1] = (L["CODEX_PROPS_CLASS_" .. tostring(m.c)] or tostring(m.c)) end + if m.out then tags[#tags + 1] = L.CODEX_ZONE_OUTLIER end + D:Add({ style = "link", text = UnitTitle(ent, m.id, "units") .. " (" .. (RangeText(m.l1, m.l2) or "?") .. ")", + right = #tags > 0 and table.concat(tags, ", ") or nil, onClick = function() Go("units", m.id) end, icon = ICON.units }) + end + -- Quests, deren Geber in diesem Gebiet steht (nur beim Oeffnen berechnet) + if key > 0 then + local pl = UnitLevel and UnitLevel("player") or 1 + local here, foryou = {}, {} + for qid, q in pairs(Codex.V().quests) do + local g = q.give + if g and g.m == key then + local e = { id = qid, q = q, lvl = q.lvl or 0, mn = q.mn or 0, title = Codex.QuestTitle(q, qid) or "" } + here[#here + 1] = e + if e.mn <= pl and e.lvl >= pl - 4 then + local st = Codex.QuestStatus(qid) + if st ~= "done" and st ~= "active" then foryou[#foryou + 1] = e end + end + end + end + local function ByLevel(a, b) + if a.lvl ~= b.lvl then return a.lvl < b.lvl end + if a.title ~= b.title then return a.title < b.title end + return a.id < b.id + end + table.sort(here, ByLevel) + table.sort(foryou, ByLevel) + local function Row(e) + local right = e.mn > 1 and string.format(L.CODEX_ZONE_QMIN, e.mn) or nil + local st = Codex.QuestStatus(e.id) + if st == "done" then right = "|cff6fd66f" .. L.CODEX_STATUS_DONE .. "|r" elseif st == "active" then right = "|cffffd060" .. L.CODEX_STATUS_ACTIVE .. "|r" end + QuestLink(e.id, e.q, right) + end + if #foryou > 0 then + D:Add({ style = "heading", text = string.format(L.CODEX_SEC_ZONE_FORYOU, #foryou, pl) }) + for i, e in ipairs(foryou) do + if i > 20 then break end + Row(e) + end + end + if #here > 0 then + D:Add({ style = "heading", text = string.format(L.CODEX_SEC_ZONE_QUESTS, #here) }) + for i, e in ipairs(here) do + if i > 80 then break end + Row(e) + end + end + -- Flugpunkte aus dem Karten-Scan + local flights = {} + for _, v in pairs((Codex.db.cat and Codex.db.cat.x) or {}) do + if type(v) == "string" then + local a = tonumber(v:match("areaID=(%d+)")) + local name = v:match("|(.+)$") + if a == key and name then flights[#flights + 1] = name end + end + end + table.sort(flights) + if #flights > 0 then + D:Add({ style = "heading", text = string.format(L.CODEX_SEC_ZONE_FLIGHT, #flights) }) + for _, name in ipairs(flights) do D:Add({ text = name }) end + end + end + -- Spanne von Hand setzen (nur Berechtigte) + if Codex.Trust and Codex.Trust.CanEdit() and Codex.NameUI and Codex.NameUI.OpenZone then + D:Add({ style = "heading", text = L.CODEX_SEC_ZONE_EDIT }) + D:Add({ style = "link", text = L.CODEX_ZONE_EDIT, onClick = function() Codex.NameUI.OpenZone(key) end }) + end + end + + local function ShowLearn(lv) + local plan = learnSel and Codex.LearnPlan(learnSel) + local g = plan and plan.levels[lv] + if not g then return end + local pl = UnitLevel and UnitLevel("player") or 1 + ShowPortrait(ICON.learn, nil) + local head = lv > 0 and string.format(L.CODEX_LEARN_TITLE, lv) or L.CODEX_LEARN_LVUNKNOWN + D:Add({ style = "title", text = head .. " - " .. Codex.Time.ClassLabel(learnSel), indent = 76 }) + D:Add({ style = "sub", text = string.format(L.CODEX_LEARN_SUB, #g.spells, FormatMoney(g.cost)), indent = 76 }) + D.y = math.max(D.y, 72) + if lv > pl then + D:Add({ text = string.format(L.CODEX_LEARN_CUM, pl + 1, FormatMoney(Codex.LearnCost(plan, pl, lv))) }) + end + D:Add({ style = "sub", text = L.CODEX_LEARN_NOTE }) + D:Add({ style = "heading", text = string.format(L.CODEX_SEC_LEARN_SPELLS, #g.spells) }) + for _, sp in ipairs(g.spells) do + local rank = sp.rk or (sp.sp and Codex.SpellRank(sp.sp)) or nil + local icon = sp.sp and Codex.SpellIcon(sp.sp) or nil + D:Add({ text = (sp.nm or "?") .. (rank and (" |cff9a9aa3(" .. rank .. ")|r") or ""), + right = (sp.cost and sp.cost > 0) and FormatMoney(sp.cost) or "-", rightWidth = 150, + spellID = sp.sp, icon = icon or (sp.sp and "Interface\\Icons\\INV_Misc_QuestionMark") or nil }) + end + D:Add({ style = "heading", text = string.format(L.CODEX_SEC_LEARN_TRAINERS, #g.trainers) }) + for i, id in ipairs(g.trainers) do + if i > 20 then break end + local ent = Codex.V().units[id] + local spot = ent and ZoneOf(ent) + EntityLink("units", id, ent, spot) + end + end + + local function ShowXP(key) + local pl, ml, cls, kindFlag = key:match("^(%d+):(%d+):(%a+):(%a)$") + pl, ml = tonumber(pl), tonumber(ml) + if not pl then return end + local rows = Codex:XPRows(true) + local r + for _, row in ipairs(rows) do + if XPKey(row) == key then r = row break end + end + if not r then return end + ShowPortrait(ICON.xp, nil) + D:Add({ style = "title", text = string.format(L.CODEX_XP_TITLE, pl, ml), indent = 76 }) + local sub = {} + local label = ClassLabel(cls) + if label then sub[#sub + 1] = label end + if r.rested then + sub[#sub + 1] = L.CODEX_XP_RESTED_TAG + elseif r.bonus then + sub[#sub + 1] = L.CODEX_XP_BONUS_TAG + end + D:Add({ style = "sub", text = table.concat(sub, " | "), indent = 76 }) + D.y = math.max(D.y, 72) + + D:Add({ style = "heading", text = L.CODEX_SEC_XPMEAS }) + D:Add({ text = L.CODEX_XP_SAMPLES, right = tostring(r.n) }) + D:Add({ text = L.CODEX_XP_AVG, right = string.format("%.2f", r.avg) }) + D:Add({ text = L.CODEX_XP_MINMAX, right = string.format("%d / %d", r.min, r.max) }) + + local secs, nTTK + if not r.bonus and cls == "normal" then + secs, nTTK = OwnTTK(pl, ml) + end + if secs and secs > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_TIME }) + D:Add({ text = string.format(L.CODEX_TTK_AVG, Codex.Time.ClassName()), right = string.format("%.1f s (n=%d)", secs, nTTK) }) + D:Add({ text = L.CODEX_TTK_XPM, right = string.format("%d", r.avg / secs * 60) }) + end + + if not r.bonus and cls == "normal" and Codex.Share and Codex.Share.TTKClasses then + local classes = Codex.Share.TTKClasses(pl, ml - pl) + if #classes > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_TTK_CLASSES }) + local mine = Codex.Time and Codex.Time.PlayerToken() + for _, c in ipairs(classes) do + D:Add({ + text = Codex.Time.ClassLabel(c.cls) .. (c.cls == mine and (" " .. L.CODEX_TTK_YOU) or ""), + right = string.format("%.1f s (n=%d) | %d XP/min", c.secs, c.n, r.avg / c.secs * 60), + }) + end + end + end + + D:Add({ style = "heading", text = L.CODEX_SEC_XPCALC }) + D:Add({ text = L.CODEX_XP_BASE, right = tostring((ml >= pl and pl or ml) * 5 + 45) }) + D:Add({ text = L.CODEX_XP_COLOR, right = L["CODEX_COLOR_" .. r.color] or r.color }) + D:Add({ text = L.CODEX_XP_ZD, right = tostring(Codex.ZeroDiff(pl)) }) + D:Add({ text = L.CODEX_XP_GRAY, right = tostring(Codex.GrayLevel(pl)) }) + D:Add({ text = L.CODEX_XP_PRED, right = tostring(r.pred) }) + D:Add({ text = L.CODEX_XP_DIFF, right = string.format("%+.2f", r.diff) }) + D:Add({ style = "sub", text = L.CODEX_XP_NOTE }) + end + + local function ShowItem(itemID) + local row = D:Add({ style = "title", text = Codex:ItemName(itemID) or ("#" .. itemID), icon = "Interface\\Icons\\INV_Misc_QuestionMark" }) + row.itemID = itemID + row:EnableMouse(true) + local q0 = Codex.QualityOf(itemID) + if q0 then row.left:SetText(Colored(Codex:ItemName(itemID) or ("#" .. itemID), q0)) end + Util.RequestItem(itemID, function(info) + if info then + Codex.RememberQuality(itemID, info.quality) + row.icon:SetTexture(info.icon) + row.left:SetText(Colored(Codex:ItemName(itemID) or info.name, info.quality)) + end + end) + do + local fx = ItemIndex()[itemID] + if fx and fx.ah then + D:Add({ style = "sub", text = "ID " .. itemID .. " | |cff8a8a96[" .. L.CODEX_AH_TAG .. "]|r" }) + D:Add({ style = "sub", text = L.CODEX_AH_NOTE }) + else + D:Add({ style = "sub", text = "ID " .. itemID }) + end + end + local ied = (Codex.V().ied or {})[itemID] + local iz = ied and (ied.ez or (ied.ed and ied.ed.z)) + local idg = ied and (ied.dg ~= nil and ied.dg or (ied.ed and ied.ed.dg)) + if iz or idg ~= nil then + local bits = {} + if iz and #iz > 0 then + local names = {} + for _, m in ipairs(iz) do names[#names + 1] = Codex.MapName(m) end + bits[#bits + 1] = string.format(L.CODEX_PROPS_ZONES_LINE, table.concat(names, ", ")) + end + if idg ~= nil then bits[#bits + 1] = idg and L.CODEX_PROPS_DG_YES or L.CODEX_PROPS_DG_NO end + D:Add({ style = "sub", text = table.concat(bits, " | ") }) + end + do + local belongs = Codex.EntityQuests("i", itemID) + if #belongs > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_QBEL }) + for i, qid in ipairs(belongs) do + if i > 30 then break end + QuestLink(qid, Codex.V().quests[qid], L.CODEX_MANUAL_TAG) + end + end + end + + -- Auktionshaus (nur mit Auktions-Modul) + local ai = Codex.AuctionInfo and Codex.AuctionInfo(itemID) + if ai then + D:Add({ style = "heading", text = L.CODEX_SEC_AH }) + local thin = ai.thin and " |cffff8000~|r" or "" + if ai.price then D:Add({ text = L.CODEX_AH_PRICE, right = FormatMoney(ai.price) .. thin }) end + if ai.min then D:Add({ text = L.CODEX_AH_MIN, right = FormatMoney(ai.min) }) end + if ai.avg then D:Add({ text = L.CODEX_AH_AVG, right = FormatMoney(ai.avg) }) end + if ai.qty then D:Add({ text = L.CODEX_AH_QTY, right = tostring(ai.qty) .. thin }) end + if ai.age then + local when = ai.age == 0 and L.CODEX_AH_TODAY or string.format(L.CODEX_AH_DAYS, ai.age) + if ai.stale then when = "|cff9d9d9d" .. when .. " (" .. L.CODEX_AH_OLD .. ")|r" end + D:Add({ text = L.CODEX_AH_AGE, right = when }) + end + if ai.thin then D:Add({ style = "sub", text = L.CODEX_AH_THIN }) end + local function Pct(v) + local color = v > 0.005 and "|cff1eff00" or (v < -0.005 and "|cffff4040" or "|cff9d9d9d") + return string.format("%s%+.0f %%|r", color, v * 100) + end + if ai.trend then D:Add({ text = L.CODEX_AH_TREND_DAY, right = Pct(ai.trend) }) end + if ai.trendAll then D:Add({ text = L.CODEX_AH_TREND_ALL, right = Pct(ai.trendAll) }) end + if ai.net then D:Add({ text = L.CODEX_AH_NET, right = FormatMoney(ai.net) }) end + local info = Util.GetItem(itemID) + local vendor = info and info.sellPrice or 0 + if vendor > 0 and ai.net then + D:Add({ text = L.CODEX_AH_VENDOR_SELL, right = FormatMoney(vendor) }) + local diff = ai.net - vendor + if diff > 0 then + D:Add({ text = L.CODEX_AH_SELL_AH, right = "|cff1eff00+" .. FormatMoney(diff) .. "|r" }) + else + D:Add({ text = L.CODEX_AH_SELL_VENDOR, right = "|cff9d9d9d+" .. FormatMoney(-diff) .. "|r" }) + end + end + local buy = LowestBuyPrices()[itemID] + if buy and ai.net then + D:Add({ text = L.CODEX_AH_VENDOR_BUY, right = FormatMoney(buy) }) + local margin = ai.net - buy + if margin > 0 then + D:Add({ text = L.CODEX_AH_FLIP, right = "|cff1eff00+" .. FormatMoney(margin) .. " (" .. string.format("%+.0f %%", margin / buy * 100) .. ")|r" }) + else + D:Add({ text = L.CODEX_AH_FLIP, right = "|cffff4040-" .. FormatMoney(-margin) .. "|r" }) + end + end + D:Chart(ai.history) + end + + -- Werte (Staerke, Beweglichkeit ...), vom Spiel gelesen + local stats = Codex:GetStats(itemID) + if stats and next(stats) then + local keys = Codex:StatKeys({ itemID }) + D:Add({ style = "heading", text = L.CODEX_SEC_STATS }) + if stats._dmin and stats._dmax and stats._spd then + D:Add({ style = "line", text = L.CODEX_WEAPON_DAMAGE, + right = string.format("%s - %s", Codex.FormatStat(stats._dmin), Codex.FormatStat(stats._dmax)) }) + D:Add({ style = "line", text = L.CODEX_WEAPON_SPEED, right = string.format("%.2f", stats._spd) }) + end + for _, key in ipairs(keys) do + if not Codex.FXFLAGS[key] and type(stats[key]) == "number" then + D:Add({ style = "line", text = Codex.StatName(key), right = Codex.FormatStat(stats[key]) }) + end + end + if type(stats._fx) == "table" and #stats._fx > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_EFFECTS }) + for _, txt in ipairs(stats._fx) do + D:Add({ style = "text", text = txt }) + end + end + end + + local sources = {} + for _, pair in ipairs({ { "units", Codex.V().units }, { "objects", Codex.V().objects } }) do + for id, ent in pairs(pair[2]) do + local chance, n, k = Codex.DropChance(ent, itemID, false) + if chance then + sources[#sources + 1] = { kind = pair[1], id = id, ent = ent, chance = chance, n = n, k = k } + end + end + end + table.sort(sources, function(a, b) + if a.chance ~= b.chance then return a.chance > b.chance end + return a.k > b.k + end) + local value = ItemValueOf(itemID) + if value and value > 0 then + local ranked = {} + for _, src in ipairs(sources) do + local ttk = src.kind == "units" and UnitTTK(src.ent) or nil + if ttk then + ranked[#ranked + 1] = { src = src, ttk = ttk, gph = src.chance * value / ttk * 3600 } + end + end + table.sort(ranked, function(a, b) return a.gph > b.gph end) + if #ranked > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_BESTSRC }) + for i = 1, math.min(3, #ranked) do + local r = ranked[i] + EntityLink(r.src.kind, r.src.id, r.src.ent, string.format(L.CODEX_BESTSRC_ROW, FormatMoney(r.gph), r.ttk)) + end + local top = ranked[1].src + D:Add({ style = "link", text = L.CODEX_MAP_SHOW, icon = MAP_ICON, + onClick = function() Codex.Map:Show(Codex.CollectPoints({ kind = top.kind, id = top.id }), UnitTitle(top.ent, top.id, top.kind)) end }) + end + end + if #sources > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_DROPS }) + for i, s in ipairs(sources) do + if i > 40 then break end + EntityLink(s.kind, s.id, s.ent, string.format(L.CODEX_DROP_CHANCE, FormatChance(s.chance), s.n, s.k)) + end + local merged = {} + for _, s in ipairs(sources) do + Codex.AddCells(merged, s.ent.cells) + end + if next(merged) then + D:Add({ style = "heading", text = L.CODEX_SEC_SPOTS }) + SpotRows(merged, 6) + MapLink({ kind = "items", id = itemID }, Codex:ItemName(itemID) or ("#" .. itemID)) + end + end + + -- Haendler laut eigenen und geteilten Beobachtungen (mit allen Preisen) + local liveVendors = {} + for vid, vent in pairs(Codex.V().units) do + local w = vent.wa and vent.wa[itemID] + if w then liveVendors[#liveVendors + 1] = { id = vid, ent = vent, w = w } end + end + for _, v in ipairs(liveVendors) do + local low + for price in pairs(v.w.pr or {}) do + if not low or price < low then low = price end + end + v.low = low or 0 + end + table.sort(liveVendors, function(a, b) + if a.low ~= b.low then return a.low < b.low end + return a.id < b.id + end) + local liveVendorSet = {} + if #liveVendors > 0 then + D:Add({ style = "heading", text = L.CODEX_SEC_SOLDBY_LIVE }) + for i, v in ipairs(liveVendors) do + liveVendorSet[v.id] = true + if i <= 30 then + local text = PriceText(v.w.pr, v.w.ec) + if i == 1 and #liveVendors > 1 then text = "|cff6fd66f" .. L.CODEX_CHEAPEST .. "|r " .. (text or "") end + EntityLink("units", v.id, v.ent, text) + end + end + end + + -- Quellen laut Vorlage (Vanilla): nur das, was wir selbst noch nicht gesehen haben + local it = SeedOK() and Codex.Seed.Item(itemID) or nil + local itFromStart = false + if not it and Codex.Forever and Codex.Forever.Item then + it = Codex.Forever.Item(itemID) + itFromStart = it ~= nil + end + if it then + local liveSet = {} + for _, src in ipairs(sources) do liveSet[src.kind .. src.id] = true end + local rows = {} + for _, def in ipairs({ { "units", it.u, 25 }, { "objects", it.o, 8 } }) do + local n = 0 + for _, src in ipairs(def[2]) do + if not liveSet[def[1] .. src.id] and n < def[3] then + n = n + 1 + rows[#rows + 1] = { kind = def[1], id = src.id, chance = src.chance } + end + end + end + if #rows > 0 then + D:Add({ style = "heading", text = itFromStart and L.CODEX_SEC_SRC_START or L.CODEX_SEC_SRC_SEED }) + for _, r in ipairs(rows) do + EntityLink(r.kind, r.id, Codex.V()[r.kind][r.id], SeedPct(r.chance)) + end + end + if #it.v > 0 then + D:Add({ style = "heading", text = itFromStart and L.CODEX_SEC_SOLDBY_START or L.CODEX_SEC_SOLDBY }) + local shownV = 0 + for _, vid in ipairs(it.v) do + if not liveVendorSet[vid] then + shownV = shownV + 1 + if shownV > 20 then break end + EntityLink("units", vid, Codex.V().units[vid], nil) + end + end + end + if #sources == 0 and #Codex.CollectPoints({ kind = "items", id = itemID }) > 0 then + MapLink({ kind = "items", id = itemID }, Codex:ItemName(itemID) or ("#" .. itemID)) + end + end + + local rewardOf, itemFor = {}, {} + for qid, q in pairs(Codex.V().quests) do + local found = false + for _, r in ipairs(q.fixed or {}) do if r.id == itemID then found = true end end + for _, r in ipairs(q.choice or {}) do if r.id == itemID then found = true end end + if found then rewardOf[#rewardOf + 1] = qid end + if q.qi and q.qi[itemID] then itemFor[#itemFor + 1] = qid end + end + local startsQ = {} + if Codex.Catalog and Codex.Catalog.Reverse then + local rev = Codex.Catalog.Reverse() + for _, def in ipairs({ { rewardOf, rev.reward[itemID] }, { itemFor, rev.item[itemID] }, { startsQ, rev.src[itemID] } }) do + local have = {} + for _, qid in ipairs(def[1]) do have[qid] = true end + for _, qid in ipairs(def[2] or {}) do + if not have[qid] then + have[qid] = true + def[1][#def[1] + 1] = qid + end + end + end + end + for _, def in ipairs({ { rewardOf, L.CODEX_SEC_REWARDOF }, { itemFor, L.CODEX_SEC_QUESTITEM }, { startsQ, L.CODEX_SEC_STARTS } }) do + if #def[1] > 0 then + D:Add({ style = "heading", text = def[2] }) + for i, qid in ipairs(def[1]) do + if i > 30 then break end + QuestLink(qid, Codex.V().quests[qid]) + end + end + end + end + + function ShowDetail() + UpdateFavUI() + D:Reset() + if selected then + if kind == "quests" then ShowQuest(selected) + elseif kind == "items" then ShowItem(selected) + elseif kind == "xp" then ShowXP(selected) + elseif kind == "learn" then ShowLearn(selected) + elseif kind == "zones" then ShowZone(selected) + else ShowEntity(kind, selected) end + end + D:Finish() + detail:ScrollToTop() + end + + -- Neuzeichnen der Detailseite (Antworten des Servers auf Namensanfragen), Scrollstand bleibt erhalten + function Codex.RedrawDetail() + if not (f:IsShown() and selected) then return end + local sc = detail.scroll + local v = sc and sc.GetVerticalScroll and sc:GetVerticalScroll() or 0 + ShowDetail() + if v > 0 and sc and sc.SetVerticalScroll then sc:SetVerticalScroll(v) end + end + + ---------------- Liste ---------------- + local function FilterKey() + local parts = {} + for q in pairs(flt.q) do parts[#parts + 1] = "q" .. q end + table.sort(parts) + for _, k in ipairs({ "type", "sub", "slot", "src", "zone", "ilMin", "ilMax", "rlMin", "rlMax", "pMin", "pMax", "aMin", "aMax", "stat", "statMin", "statMax" }) do + if flt[k] ~= nil then parts[#parts + 1] = k .. "=" .. tostring(flt[k]) end + end + for i = 1, MAX_STAT_ROWS do + local r = flt.stats and flt.stats[i] + if r and r.key then + parts[#parts + 1] = string.format("s%d=%s:%s:%s:%s", i, r.key, tostring(r.min), tostring(r.max), + flt.ops and flt.ops[i] or "and") + end + end + return table.concat(parts, ",") + end + + -- Sortierwerte erst berechnen, wenn danach sortiert wird; fehlende Werte bleiben nil und stehen am Ende + local function Enrich(entries, mode) + local V = Codex.V() + if kind == "units" or kind == "objects" then + for _, e in ipairs(entries) do + local u = V[kind][e.id] + if mode == "ahkill" and kind == "units" then + local value = u and Codex.KillValue(u, true) + e.ahkill = value and value >= 0 and value or nil + elseif mode == "ttk" or mode == "gpm" or mode == "gpmah" or mode == "xpm" then + local ttk = kind == "units" and UnitTTK(u) or nil + e.ttk = ttk + if ttk then + if mode == "gpm" or mode == "gpmah" then + local value = Codex.KillValue(u, mode == "gpmah") + e[mode] = value and value >= 0 and (value / ttk * 60) or nil + elseif mode == "xpm" then + local xp = UnitXPAvg(u) + e.xpm = xp and (xp / ttk * 60) or nil + end + end + elseif mode == "data" then + e.data = u and ((u.k or 0) + (u.tt and 1 or 0)) or 0 + elseif mode == "lvl" then + local lv = u and u.l1 + if not lv and SeedOK() then + local ent = Codex.Seed.Entity(kind, e.id) + lv = ent and ent.l1 + end + e.lvl = lv + end + end + elseif kind == "quests" then + for _, e in ipairs(entries) do + local q = V.quests[e.id] + if mode == "lvl" then + e.lvl = q and q.lvl or nil + elseif mode == "xp" then + e.xp = q and q.xp or nil + elseif mode == "money" then + e.money = q and q.money or nil + elseif mode == "qtime" then + e.qtime = q and q.tm and q.tm.n and q.tm.n > 0 and (q.tm.sum / q.tm.n / 10) or nil + end + end + elseif kind == "items" then + if mode == "buy" then + local lowest = LowestBuyPrices() + for _, e in ipairs(entries) do e.buy = lowest[e.id] end + elseif mode == "flip" then + -- Gewinn je Stueck: Auktion (netto nach Gebuehr) minus Einkauf beim Haendler + local lowest = LowestBuyPrices() + for _, e in ipairs(entries) do + local info = Codex.AuctionInfo and Codex.AuctionInfo(e.id) + local buy = lowest[e.id] + e.flip = info and info.net and buy and (info.net - buy) or nil + end + elseif mode == "ah" then + for _, e in ipairs(entries) do + local v = Codex.AuctionProvider and Codex.AuctionPrice(e.id) + e.ah = v and v > 0 and v or nil + end + elseif mode == "gph" then + local best = {} + for _, u in pairs(V.units) do + local k = u.k or 0 + local ttk = k > 0 and UnitTTK(u) or nil + if ttk then + for id, d in pairs(u.d or {}) do + local p = ItemValueOf(id) + if p and p > 0 then + local g = (d.q or d.n or 0) / k * p / ttk * 3600 + if g > (best[id] or 0) then best[id] = g end + end + end + end + end + for _, e in ipairs(entries) do e.gph = best[e.id] end + end + end + -- Sortierwert in der Zeile anzeigen + local fmt = SORT_FORMAT[mode] + local metric = SORT_METRIC[mode] + for _, e in ipairs(entries) do + e.baseSub = e.baseSub or e.sub + local v = metric and e[metric] + if fmt and v and v >= 0 and not (mode == "gold" or mode == "price") then + local text + if fmt == "money" then text = FormatMoney(v) + elseif fmt == "secs" then text = string.format("%.1f s", v) + else text = string.format("%.0f", v) end + e.sub = (e.baseSub ~= "" and (e.baseSub .. " | ") or "") .. (L["CODEX_SORTBY_" .. mode] or mode) .. ": " .. text + else + e.sub = e.baseSub + end + end + end + + local function Missing(v) + return v == nil or v < 0 or v >= 1e9 + end + + local function SortedBase() + local mode = CurrentSort() + local seed = SeedOn() + local role = roleSel[kind] + local reverse = sortRev[kind] == true + -- unveraendert (zum Beispiel beim Anklicken einer Zeile): die gemerkte Liste wiederverwenden + local key = table.concat({ kind, search, mode, tostring(reverse), tostring(farmOn and kind == "units"), tostring(seed), tostring(role), tostring(chainSel), tostring(statusSel), tostring(zoneSel), tostring(learnSel), tostring(favOnly[kind]), FilterKey(), dataVersion }, "\1") + -- Quest-Status aendert sich im Spiel laufend: mit Statusfilter immer neu aufbauen + if memo.key == key and mode ~= "dist" and not statusSel then + return memo.list + end + local entries = BuildEntries(kind, search, flt, { seed = seed, role = role, chain = chainSel, status = statusSel, zoneSel = zoneSel, + class = kind == "learn" and EnsureLearnSel() or nil, fav = favOnly[kind] }) + local metric = SORT_METRIC[mode] + if mode == "dist" then + local Seed = Codex.Seed + local loc = Util.GetPlayerLocation() -- einmal lesen, nicht je Eintrag + for _, e in ipairs(entries) do + if kind == "quests" then + local q = Codex.V().quests[e.id] + local g = q and q.give + local d + if g and g.m and g.x and g.y and loc and loc.mapID == g.m and loc.x and loc.y then + d = math.sqrt((g.x - loc.x) ^ 2 + (g.y - loc.y) ^ 2) + end + e.dist = d or 1e9 + else + local live = Codex.V()[kind][e.id] + local d1 = live and live.cells and Seed.Distance(live.cells, loc) + local d2 = SeedOK() and Seed.Distance(Seed.Cells(kind, e.id), loc) + e.dist = math.min(d1 or 1e9, d2 or 1e9) + end + end + end + if farmOn and kind == "units" then + local loc = Util.GetPlayerLocation() + local zone = loc and loc.mapID and CachedMapName(loc.mapID) + zone = zone and zone:lower() or nil + local pl = UnitLevel and UnitLevel("player") or 1 + Enrich(entries, "gpm") + local kept = {} + for _, e in ipairs(entries) do + if e.gpm and e.l1 and (e.l2 or e.l1) >= pl - 5 and e.l1 <= pl + 3 and (not zone or e.zone == zone) then + kept[#kept + 1] = e + end + end + entries = kept + end + Enrich(entries, mode) + local asc = SORT_ASC[mode] == true + if reverse then asc = not asc end + table.sort(entries, function(a, b) + local va, vb + if mode == "zone" then + local za, zb = a.zone ~= "" and a.zone or "\255", b.zone ~= "" and b.zone or "\255" + if za ~= zb then + if asc then return za < zb end + return za > zb + end + if a.name ~= b.name then return a.name < b.name end + return a.id < b.id + end + if mode == "recent" then + va, vb = a.recent, b.recent + elseif mode == "dist" then + va, vb = a.dist, b.dist + local ma, mb = Missing(va), Missing(vb) + if ma ~= mb then return mb end + if ma then va, vb = 0, 0 end + elseif metric then + va, vb = a[metric], b[metric] + local ma, mb = Missing(va), Missing(vb) + if ma ~= mb then return mb end -- fehlende Werte immer ans Ende + if ma then va, vb = 0, 0 end + end + if va ~= nil and va ~= vb then + if asc then return va < vb end + return va > vb + end + if a.name ~= b.name then + if mode == "name" then + if asc then return a.name < b.name end + return a.name > b.name + end + return a.name < b.name + end + return a.id < b.id + end) + memo.key, memo.list = key, entries + return entries + end + + -- Sortierung nach Gebiet: Kopfzeilen mit Zaehlern, einklappbar. Zaehler immer frisch (Questlog aendert sich). + local function SortedEntries() + local entries = SortedBase() + if kind ~= "quests" or CurrentSort() ~= "zone" then return entries end + local L = FW.L + local out, i = {}, 1 + while i <= #entries do + local zone = entries[i].zone or "" + local j, open, active, done = i, 0, 0, 0 + while j <= #entries and (entries[j].zone or "") == zone do + local st = Codex.QuestStatus(entries[j].id) + if st == "done" then done = done + 1 elseif st == "active" then active = active + 1 else open = open + 1 end + j = j + 1 + end + local count = j - i + local name = entries[i].zoneName or L.CODEX_ZONE_UNKNOWN + local closed = collapsed[zone] == true + out[#out + 1] = { header = zone, id = "zone:" .. zone, title = string.format("%s (%d)", name, count), + sub = string.format(L.CODEX_GROUP_ROW, open, active, done), icon = closed and ICON.plus or ICON.minus, + name = "", zone = zone } + if not closed then + for k = i, j - 1 do out[#out + 1] = entries[k] end + end + i = j + end + return out + end + + -- Kreatur-Bilder in der Liste: 2D-Portrait aus der gespeicherten Display-ID. + -- Kreaturen ohne ID zeigen das Symbol; ein einziges unsichtbares Modell holt die IDs + -- der sichtbaren Zeilen nach und zwar eine nach der anderen. + local resolver = CreateFrame("PlayerModel", nil, f) + resolver:SetSize(8, 8) + resolver:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, 0) + resolver:SetAlpha(0) + local resolverBusy, resolverTries = false, {} + local UpdateRowIcons + + local function ReadDisplayID(model) + local ok, id = pcall(function() return model.GetDisplayInfo and model:GetDisplayInfo() end) + if ok and type(id) == "number" and id > 0 then return id end + return nil + end + + local function ResolveNext() + -- Ohne 2D-Portrait in der Liste (Octo) braucht niemand mehr die Display-IDs der Zeilen; + -- unbekannte Kreaturen ueber ein Modell zu laden waere ausserdem riskant (siehe SAFE_MODEL_TYPE). + if true then return end + if resolverBusy then return end + local sf = listScroll.scroll + local top = sf:GetVerticalScroll() or 0 + local bottom = top + (sf:GetHeight() or 0) + local pick + for i, row in ipairs(listRows) do + local npc = row.unitID + if npc and row:IsShown() and not Codex:GetDisplayID(npc) and not resolverTries[npc] then + local y = (i - 1) * (ROW_H + ROW_GAP) + if y + ROW_H >= top and y <= bottom then pick = npc break end + end + end + if not pick then return end + resolverTries[pick] = true + resolverBusy = true + local function Finish() + local d = ReadDisplayID(resolver) + if d then + Codex.dispStats.read = Codex.dispStats.read + 1 + if Codex:SetDisplayID(pick, d) then UpdateRowIcons() end + else + Codex.dispStats.fail = Codex.dispStats.fail + 1 + end + resolverBusy = false + C_Timer.After(0.05, ResolveNext) + end + Codex.EnsureCreature(pick, function() + local cd = Codex.CacheDisplayID(pick) + if cd then + Codex.dispStats.read = Codex.dispStats.read + 1 + if Codex:SetDisplayID(pick, cd) then UpdateRowIcons() end + resolverBusy = false + C_Timer.After(0.05, ResolveNext) + return + end + pcall(function() + resolver:ClearModel() + resolver:SetCreature(pick) + end) + C_Timer.After(0.35, Finish) + end) + end + + -- Octo hat kein SetPortraitTextureFromCreatureDisplayID und ein 2D-Bild geht nur fuer + -- Einheiten-Token. Mehrere 3D-Modelle in der Liste fuehrten zu Abstuerzen, daher bleibt + -- die Liste bei den Symbolen; das 3D-Modell gibt es nur im Detailfenster. + function UpdateRowIcons() + for _, row in ipairs(listRows) do + if row.fwModel then row.fwModel:Hide() end + row.icon:SetAlpha(1) + end + end + + local function UpdateRowModels() + UpdateRowIcons() + ResolveNext() + end + f.UpdateRowModels = UpdateRowModels + if listScroll.scroll.HookScript then + listScroll.scroll:HookScript("OnVerticalScroll", function() UpdateRowModels() end) + end + + function RefreshList() + if kind == "items" or kind == "units" or kind == "objects" then + local _, ids = ItemIndex() + Codex:LoadItemInfo(ids) + if kind == "items" then Codex:CaptureStats(ids) end + end + local entries = SortedEntries() + -- Namen der Katalog-Ziele beim Server nachfragen (je Aufruf wenige, jede ID nur einmal) + if (kind == "units" or kind == "objects") and Codex.Catalog and Codex.Catalog.Reverse then + local asked = 0 + for _, e in ipairs(entries) do + if asked >= 12 then break end + if e.title:sub(1, 1) == "#" and not srvAsked[(kind == "units" and "u" or "o") .. e.id] then + ServerInfo(kind, e.id, true) + asked = asked + 1 + end + end + if asked > 0 and _G.C_Timer then + _G.C_Timer.After(1.6, function() catEnt = {} dataVersion = dataVersion + 1 if f:IsShown() and RefreshList then RefreshList() end end) + end + end + local total = #entries + local headers = 0 + if kind == "quests" and CurrentSort() == "zone" then + for _, e in ipairs(entries) do + if e.header then headers = headers + 1 end + end + end + local shown = math.min(total, MaxRows()) + for i = 1, shown do + local e = entries[i] + local row = listRows[i] + if not row then + row = UI.CreatePanel(listScroll.child, "card", "bronze") + row:SetHeight(ROW_H) + row:EnableMouse(true) + row.icon = row:CreateTexture(nil, "ARTWORK") + row.icon:SetSize(30, 30) + row.icon:SetPoint("LEFT", row, "LEFT", 7, 0) + row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + row.title = UI.CreateLabel(row, "", { size = 12 }) + row.title:SetPoint("TOPLEFT", row, "TOPLEFT", 46, -7) + row.title:SetPoint("TOPRIGHT", row, "TOPRIGHT", -8, -7) + row.title:SetWordWrap(false) + row.sub = UI.CreateLabel(row, "", { size = 10, color = "textDim" }) + row.sub:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 46, 7) + row.sub:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -8, 7) + row.sub:SetWordWrap(false) + -- Quest-Status rechts: Haken (erledigt) oder Sprint-Symbol (in Arbeit) + row.mark = row:CreateTexture(nil, "OVERLAY") + row.mark:SetSize(18, 18) + row.mark:SetPoint("RIGHT", row, "RIGHT", -8, 0) + row.mark:Hide() + row.fav = row:CreateTexture(nil, "OVERLAY") + row.fav:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcon_1") + row.fav:SetSize(14, 14) + row.fav:Hide() + row:SetScript("OnMouseUp", function(self) + if self.headerKey then + collapsed[self.headerKey] = not collapsed[self.headerKey] + RefreshList() + return + end + selected = self.entryID + RefreshList() + ShowDetail() + if nameCtl then nameCtl.Update() end + end) + row:SetScript("OnEnter", function(self) + UI.SetBorderColor(self, "gold") + if self.itemID and GameTooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + local ok = pcall(GameTooltip.SetItemByID, GameTooltip, self.itemID) + if not ok then pcall(GameTooltip.SetHyperlink, GameTooltip, "item:" .. self.itemID) end + GameTooltip:Show() + end + end) + row:SetScript("OnLeave", function(self) + UI.SetBorderColor(self, (self.entryID == selected or self.headerKey) and "gold" or "bronze") + if GameTooltip then GameTooltip:Hide() end + end) + listRows[i] = row + end + row.entryID = e.id + row.headerKey = e.header + row.itemID = e.itemID + row.unitID = e.unitID + row.dispShown = nil + row:ClearAllPoints() + local y = (i - 1) * (ROW_H + ROW_GAP) + row:SetPoint("TOPLEFT", listScroll.child, "TOPLEFT", 0, -y) + row:SetPoint("TOPRIGHT", listScroll.child, "TOPRIGHT", 0, -y) + row.title:SetText(e.header and ("|cffffd100" .. e.title .. "|r") or e.title) + row.sub:SetText(e.sub or "") + local mark = (kind == "quests" and not e.header) and STATUS_MARK[Codex.QuestStatus(e.id) or ""] or nil + row.markState = mark + local isFav = (not e.header) and Codex.FavSupported(kind) and Codex.IsFav(kind, e.id) + row.fav:ClearAllPoints() + row.fav:SetPoint("RIGHT", row, "RIGHT", mark and -30 or -8, 0) + row.fav:SetShown(isFav and true or false) + local rightGap = (mark and 32 or 8) + (isFav and 18 or 0) + row.title:ClearAllPoints() + row.title:SetPoint("TOPLEFT", row, "TOPLEFT", 46, -7) + row.title:SetPoint("TOPRIGHT", row, "TOPRIGHT", -rightGap, -7) + row.sub:ClearAllPoints() + row.sub:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 46, 7) + row.sub:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", -rightGap, 7) + if mark then + row.mark:SetTexture(mark) + row.mark:SetTexCoord(0.08, 0.92, 0.08, 0.92) + row.mark:Show() + else + row.mark:Hide() + end + row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + if e.itemID then + row.icon:SetTexture(ICON.items) + local q = Codex.QualityOf(e.itemID) + if q then row.title:SetText(Colored(e.title, q)) end + Util.RequestItem(e.itemID, function(info) + if row.entryID == e.itemID and info then + Codex.RememberQuality(e.itemID, info.quality) + row.icon:SetTexture(info.icon) + row.title:SetText(Colored(info.name, info.quality)) + end + end) + else + row.icon:SetTexture(e.icon) + row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + end + UI.SetBorderColor(row, (e.id == selected or e.header) and "gold" or "bronze") + if e.header then row.icon:SetTexCoord(0, 1, 0, 1) end + row:Show() + end + for i = shown + 1, #listRows do + listRows[i]:Hide() + end + listScroll:SetContentHeight(shown * (ROW_H + ROW_GAP)) + UpdateRowModels() + + local q, u, o = Codex:Counts() + if total == 0 then + listEmpty:SetText(kind == "learn" and #Codex.LearnClasses() == 0 and L.CODEX_LEARN_EMPTY + or (q + u + o) == 0 and L.CODEX_EMPTY or L.CODEX_NO_MATCH) + listEmpty:Show() + countLabel:SetText("") + else + listEmpty:Hide() + local text = total > shown and string.format(L.CODEX_COUNT_MORE, total - headers, shown - headers) or string.format(L.CODEX_COUNT, total - headers) + local pending = Codex.runtime.pendingInfo or 0 + if pending > 0 and kind == "items" then + text = text .. " | " .. string.format(L.CODEX_LOADING_ITEMS, pending) + end + countLabel:SetText(text) + end + end + + function Refresh() + LayoutKinds() + for id, b in pairs(kindButtons) do + b:SetStyle(id == kind and "primary" or "default") + end + if #history > 0 then backButton:Show() else backButton:Hide() end + sortDD:SetValue(CurrentSort()) + local asc = SORT_ASC[CurrentSort()] == true + if sortRev[kind] then asc = not asc end + dirButton.arrow:SetTexCoord(0, 1, asc and 0 or 1, asc and 1 or 0) + Layout() + UpdateFilterUI() + RefreshList() + ShowDetail() + if nameCtl then nameCtl.Update() end + end + + function Go(newKind, id) + if selected ~= nil and (kind ~= newKind or selected ~= id) then + history[#history + 1] = { kind = kind, id = selected } + if #history > 30 then table.remove(history, 1) end + end + kind, selected = newKind, id + edit:SetText("") + edit:ClearFocus() + Refresh() + end + + f.Refresh = function() + dirty = true + dataVersion = dataVersion + 1 + Refresh() + end + f.Go = Go + -- fuer Tests + f.Entries = SortedEntries + f.SetFilter = function(t) + flt = t or {} + flt.q = flt.q or {} + Refresh() + end + f.SetSort = function(mode, reverse) + sortBy[kind] = mode + sortRev[kind] = reverse == true + Refresh() + end + f.ToggleZone = function(zone) collapsed[zone] = not collapsed[zone]; Refresh() end + f.ToggleRoute = function() routeOpen = not routeOpen; RouteSync(); Layout(); return routeOpen end + f.RouteLevels = function() return routeLvl, routeOpen end + f.SetRouteRange = function(on) + if on then routeLvl.below, routeLvl.above = 3, 3 else routeLvl.below, routeLvl.above = nil, nil end + RouteSync() + end + f.SetFarm = function(on) + farmOn = on and true or false + if farmOn then roleSel.units, sortBy.units, sortRev.units = "combat", "gpm", false end + Refresh() + end + f.Back = function() + local prev = table.remove(history) + if prev then + kind, selected = prev.kind, prev.id + Refresh() + end + return prev ~= nil + end + f.HistoryCount = function() return #history end + f.SetLearnClass = function(token) learnSel = token; selected = nil; dataVersion = dataVersion + 1; Refresh() end + f.SetFavOnly = function(on) favOnly[kind] = on and true or false; dataVersion = dataVersion + 1; Refresh() end + f.Kind = function() return kind end + f.SetRole = function(role) + roleSel[kind] = role + Refresh() + end + f.FilterText = function() return StatPreview(flt) end + f.SetStatus = function(status) + statusSel = status + Refresh() + end + f.SetChain = function(chain) + chainSel = chain + Refresh() + end + f.SetSearch = function(text) + search = text or "" + Refresh() + end + -- Text der angezeigten Detail-Zeilen (links | rechts), fuer Tests + f.DetailText = function() + local t = {} + for i = 1, D.used do + t[#t + 1] = (detailRows[i].left:GetText() or "") .. "|" .. (detailRows[i].right:GetText() or "") + end + return table.concat(t, "\n") + end + + FW:On("CODEX_CHANGED", function() + dirty = true + dataVersion = dataVersion + 1 + if f:IsShown() then + RefreshList() + end + end, f) + FW:On("CODEX_ITEMS_LOADED", function() + dataVersion = dataVersion + 1 + if f:IsShown() and (kind == "items" or kind == "units" or kind == "objects") then + RefreshList() + if (kind == "units" or kind == "objects") and selected and detailMissing then + ShowDetail() + end + end + end, f) + + return f +end + +local built +UI:RegisterTab("codex", { + label = function() + return FW.L.CODEX_TAB + end, + order = 30, + build = function(content) + built = Build(content) + Codex.ui = built + return built + end, + onShow = function(f) + f.Refresh() + end, +}) + +------------------------------------------------------------------------ +-- /fwcodex near : naechste Haendler, Flugmeister ... auf dieser Karte (Vorlage) +------------------------------------------------------------------------ + +local NEAR_WORDS = { + { "vendor", "haendler", "händler", "vendor", "shop" }, { "repair", "repair", "reparatur", "rep" }, + { "flight", "flug", "flight", "fm" }, { "innkeeper", "gast", "inn", "wirt" }, + { "stablemaster", "stall", "stable" }, { "banker", "bank" }, { "auctioneer", "auktion", "auction", "ah" }, + { "mailbox", "post", "mail", "brief" }, { "spirithealer", "geist", "spirit", "heiler" }, +} + +function Codex.Near(word) + local L = FW.L + word = (word or ""):lower() + local role + for _, def in ipairs(NEAR_WORDS) do + for i = 2, #def do + if word == def[i] then role = def[1] end + end + end + if not role then + FW:Print(L.CODEX_NEAR_HELP) + return + end + local list = Codex.Seed.Available() and Codex.Seed.Nearest(role, 5) or {} + if #list == 0 then + FW:Print(L.CODEX_NEAR_NONE) + return + end + FW:Print(string.format(L.CODEX_NEAR_HEAD, L["CODEX_ROLE_" .. role] or role)) + local points = {} + for _, e in ipairs(list) do + local name = (Codex.NameOf(Codex.V()[e.kind][e.id])) or Codex.Seed.Name(e.kind, e.id) or ("#" .. e.id) + FW:Print(string.format(L.CODEX_NEAR_ROW, name, e.x * 100, e.y * 100)) + points[#points + 1] = { m = e.m, x = e.x, y = e.y, n = 4, pin = true, name = name } + end + if Codex.Map and Codex.Map.Show then + Codex.Map:Show(points, L["CODEX_ROLE_" .. role] or role) + end +end + +------------------------------------------------------------------------ +-- Befehl +------------------------------------------------------------------------ + +SLASH_FREEWOODCODEX1 = "/fwcodex" +SlashCmdList["FREEWOODCODEX"] = function(msg) + msg = (msg or ""):lower():match("^%s*(.-)%s*$") + if msg == "test" then + Codex:RunTest() + elseif msg == "share log" then + if Codex.Sync then Codex.Sync:SetLog(not Codex.Sync.state.log) end + elseif msg == "share on" or msg == "share off" then + if Codex.Sync then + Codex.Sync:SetEnabled(msg == "share on") + Codex.Sync:Debug() + end + elseif msg == "enc" then + if Codex.Sync then Codex.Sync:EncTest() end + elseif msg == "share" then + if Codex.Sync then Codex.Sync:Debug() end + elseif msg == "time" then + if Codex.Time then Codex.Time:Debug() end + elseif msg == "icons" then + local st = Codex.dispStats + local n = 0 + for _ in pairs(Codex.db.disp or {}) do n = n + 1 end + FW:Print(string.format("Kreatur-Bilder: %d Display-IDs gespeichert, gelesen %d, nicht lesbar %d, in der Liste gesetzt %d", n, st.read, st.fail, st.shown)) + elseif msg == "seed" then + if Codex.Seed and Codex.Seed.Available() then + FW:Print(string.format(FW.L.CODEX_SEED_STATUS, Codex.Seed.Count())) + else + FW:Print(FW.L.CODEX_SEED_MISSING) + end + elseif msg == "roles" then + Codex.Roles:Debug() + elseif msg == "trainers" then + -- Diagnose Lernplan: alle erfassten Lehrer mit Titel, Klasse und Anzahl Zauber + local n = 0 + for id, u in pairs(Codex.V().units) do + if u.ro and ((u.ro.classtrainer or 0) > 0 or (u.ro.proftrainer or 0) > 0) then + n = n + 1 + local cnt = 0 + for _ in pairs(u.tr or {}) do cnt = cnt + 1 end + FW:Print(string.format("#%d %s | Titel: %s | Klasse: %s (Titel: %s, Spieler: %s) | Zauber: %d | Rolle: %s", id, tostring(u.n), tostring(u.ti), + tostring(Codex.ClassOfTrainer(u)), tostring(Codex.ClassOfTitle(u.ti)), tostring(u.trc), cnt, (u.ro.classtrainer or 0) > 0 and "Klasse" or "Beruf")) + end + end + FW:Print(n .. " Lehrer erfasst.") + elseif msg:find("^weapon") then + local id = tonumber(msg:match("item:(%d+)")) or tonumber(msg:match("(%d+)")) + if id then Codex.WeaponDebug(id) else FW:Print("/fwcodex weapon ") end + elseif msg:find("^itemload") then + if msg:find("stop") then Codex.ItemLoadStop() + elseif msg:find("status") then Codex.ItemLoadStatus() + else Codex.ItemLoadStart(Codex.AllItemIds()) end + elseif msg:find("^tipscan") then + if msg:find("stop") then Codex.TipScanStop() else local _, ids = ItemIndex() Codex.TipScan(ids) end + elseif msg:find("^near") then + Codex.Near(msg:match("^near%s*(.-)$")) + elseif msg == "names" then + if Codex.Names then Codex.Names:Debug() end + elseif msg == "map" then + Codex.Map:Debug() + elseif msg == "map test" then + Codex.Map:Test() + elseif msg == "map square" then + Codex.Map:ToggleSquare() + else + UI:Toggle("codex") + end +end + +------------------------------------------------------------------------ +-- Einstellungen: Anzahl der Listenzeilen in der Datenbank +------------------------------------------------------------------------ + +if UI.RegisterSettingsSection then + UI:RegisterSettingsSection("codex-list", { + order = 104, + height = 110, + build = function(parent) + local L = FW.L + local f, stack = UI.CreateSection(parent, L.CODEX_LISTMAX_HEADER) + stack:Add(UI.CreateLabel(f, L.CODEX_LISTMAX_NOTE, { size = 11, color = "textDim", justifyV = "TOP" }), { fit = true, gap = 8 }) + local val = UI.CreateLabel(f, "", { size = 12 }) + stack:Add(val, { fit = true, gap = 6 }) + local slider = UI.CreateSlider(f, 240, { + min = 100, max = 6000, step = 100, + get = function() return MaxRows() end, + set = function(v) + Codex.db.settings.listMax = v + val:SetText(string.format(L.CODEX_LISTMAX_VALUE, v)) + end, + }) + slider.onText = function(v) val:SetText(string.format(L.CODEX_LISTMAX_VALUE, v)) end + stack:Add(slider, { height = 20, gap = 4 }) + stack:Layout() + f.slider = slider + return f + end, + refresh = function(frame) frame.slider:Refresh() end, + }) +end diff --git a/CodexWdbChain.lua b/CodexWdbChain.lua new file mode 100644 index 0000000..212228a --- /dev/null +++ b/CodexWdbChain.lua @@ -0,0 +1,1406 @@ +-- Erzeugt aus WDB\questcache.wdb (4205 Quests). Nicht von Hand aendern. +local ADDON, NS = ... +local Codex = NS and NS.Codex +if not Codex then return end +Codex.WdbNext = { +[5]=93, [10]=110, [12]=13, [13]=14, [15]=21, [20]=19, [26]=29, [27]=28, +[28]=30, [29]=272, [30]=31, [35]=37, [36]=38, [37]=45, [39]=59, [40]=35, +[45]=71, [48]=49, [49]=50, [50]=51, [51]=53, [56]=57, [57]=58, [60]=61, +[62]=76, [63]=100, [65]=132, [66]=67, [67]=68, [68]=69, [69]=70, [70]=72, +[71]=39, [72]=74, [74]=75, [75]=78, [76]=60154, [78]=79, [79]=80, [80]=97, +[84]=87, [85]=86, [86]=84, [93]=240, [94]=248, [95]=230, [97]=98, [100]=96, +[106]=111, [107]=112, [110]=113, [111]=107, [118]=119, [120]=121, [124]=126, [125]=89, +[129]=130, [130]=131, [132]=135, [133]=134, [134]=160, [135]=141, [136]=138, [138]=139, +[139]=140, [141]=142, [143]=144, [145]=146, [148]=149, [149]=154, [154]=157, [155]=166, +[156]=159, [157]=158, [158]=156, [159]=133, [160]=251, [161]=274, [173]=221, [174]=175, +[175]=177, [177]=181, [182]=218, [185]=186, [186]=187, [187]=188, [190]=191, [191]=192, +[192]=193, [194]=195, [195]=196, [196]=197, [199]=161, [200]=328, [207]=205, [210]=627, +[215]=200, [218]=282, [221]=222, [222]=223, [225]=227, [227]=228, [228]=229, [229]=231, +[230]=262, [232]=238, [233]=234, [238]=243, [239]=11, [243]=379, [244]=246, [250]=199, +[257]=258, [261]=1052, [262]=265, [265]=266, [266]=453, [268]=323, [269]=270, [270]=321, +[271]=531, [272]=5061, [273]=454, [274]=278, [276]=277, [277]=275, [278]=280, [279]=281, +[280]=283, [281]=284, [282]=420, [284]=285, [285]=286, [287]=291, [288]=289, [289]=290, +[290]=292, [292]=293, [294]=295, [295]=296, [298]=301, [301]=302, [302]=273, [305]=306, +[310]=311, [317]=318, [318]=319, [319]=320, [321]=324, [322]=325, [323]=269, [324]=322, +[325]=55, [328]=329, [329]=330, [330]=331, [335]=336, [337]=538, [343]=344, [344]=345, +[345]=347, [347]=346, [350]=2745, [351]=648, [355]=408, [357]=366, [359]=360, [365]=407, +[366]=409, [367]=368, [368]=369, [369]=492, [370]=371, [371]=372, [373]=389, [380]=381, +[381]=382, [382]=383, [389]=391, [391]=392, [392]=393, [393]=350, [394]=395, [395]=396, +[401]=252, [405]=357, [409]=411, [413]=414, [415]=413, [419]=417, [421]=422, [422]=423, +[423]=424, [424]=99, [427]=370, [428]=429, [429]=430, [434]=394, [435]=449, [436]=297, +[437]=438, [438]=439, [439]=443, [440]=441, [441]=530, [443]=444, [446]=448, [447]=450, +[448]=442, [449]=3221, [450]=451, [453]=268, [458]=459, [460]=461, [461]=491, [463]=276, +[464]=465, [465]=474, [467]=466, [468]=455, [473]=464, [475]=476, [476]=483, [477]=478, +[478]=481, [479]=480, [481]=482, [482]=479, [483]=486, [484]=471, [485]=836, [495]=518, +[496]=499, [500]=504, [501]=502, [503]=506, [506]=507, [507]=508, [509]=513, [510]=512, +[511]=514, [513]=515, [514]=525, [515]=517, [517]=524, [518]=519, [519]=520, [520]=521, +[522]=523, [525]=537, [526]=322, [527]=528, [528]=529, [529]=532, [532]=539, [533]=503, +[536]=559, [538]=540, [539]=541, [540]=542, [541]=550, [544]=545, [551]=554, [552]=553, +[558]=171, [559]=560, [560]=561, [561]=562, [562]=563, [568]=569, [570]=572, [571]=573, +[572]=571, [574]=202, [577]=628, [578]=601, [581]=582, [588]=589, [589]=591, [594]=630, +[595]=597, [597]=599, [599]=604, [601]=602, [603]=610, [604]=608, [605]=600, [606]=607, +[607]=609, [610]=611, [615]=618, [616]=578, [624]=625, [625]=626, [627]=622, [631]=632, +[632]=633, [633]=634, [638]=639, [639]=640, [641]=643, [642]=651, [643]=644, [644]=645, +[645]=646, [649]=650, [650]=77, [651]=652, [653]=687, [654]=864, [655]=672, [658]=657, +[659]=658, [660]=661, [665]=666, [666]=668, [668]=669, [669]=670, [672]=674, [674]=675, +[675]=701, [676]=677, [677]=678, [678]=679, [686]=689, [687]=692, [688]=687, [694]=695, +[695]=696, [696]=697, [698]=699, [699]=1422, [701]=702, [707]=738, [710]=711, [711]=712, +[713]=714, [718]=733, [720]=721, [721]=722, [722]=723, [723]=724, [725]=726, [726]=762, +[727]=735, [728]=736, [730]=729, [731]=741, [732]=717, [734]=777, [735]=737, [736]=737, +[738]=739, [739]=704, [741]=942, [749]=751, [750]=780, [752]=753, [753]=755, [755]=757, +[757]=763, [809]=924, [819]=821, [821]=822, [828]=827, [829]=809, [830]=831, [834]=835, +[838]=964, [840]=842, [843]=846, [844]=845, [845]=903, [846]=849, [851]=852, [860]=844, +[867]=875, [869]=3281, [870]=877, [874]=873, [875]=876, [877]=880, [879]=906, [881]=905, +[882]=907, [886]=870, [887]=890, [890]=892, [892]=888, [894]=900, [900]=901, [901]=902, +[903]=881, [905]=3261, [907]=913, [913]=874, [915]=925, [916]=917, [918]=922, [920]=921, +[921]=928, [922]=923, [923]=2498, [925]=5502, [928]=929, [929]=933, [933]=7383, [936]=3761, +[939]=4441, [940]=952, [942]=943, [944]=949, [947]=948, [948]=944, [949]=950, [954]=955, +[955]=956, [956]=957, [964]=5514, [965]=966, [966]=967, [967]=970, [969]=975, [973]=1140, +[974]=980, [977]=5163, [978]=979, [980]=4842, [983]=1001, [984]=985, [991]=1023, [992]=82, +[994]=990, [995]=990, [1000]=1123, [1001]=1002, [1002]=1003, [1004]=1123, [1015]=6761, [1016]=1017, +[1018]=1123, [1019]=6761, [1021]=1031, [1022]=1037, [1024]=1026, [1026]=1027, [1027]=1028, [1028]=1055, +[1029]=1030, [1030]=1045, [1036]=4621, [1037]=1038, [1038]=1039, [1039]=1040, [1040]=1041, [1041]=1042, +[1042]=1043, [1043]=1044, [1045]=1046, [1047]=6761, [1055]=1029, [1056]=1057, [1057]=1059, [1061]=1062, +[1064]=1065, [1065]=1066, [1070]=1085, [1072]=1073, [1073]=1074, [1075]=1076, [1076]=1077, [1082]=1081, +[1085]=1071, [1087]=1088, [1088]=1089, [1090]=1092, [1093]=1094, [1094]=1095, [1095]=1096, [1097]=353, +[1100]=1101, [1106]=1108, [1109]=1113, [1111]=1112, [1115]=1116, [1116]=1117, [1123]=1124, [1124]=1125, +[1125]=1126, [1126]=6844, [1130]=1131, [1132]=1133, [1140]=1167, [1143]=981, [1145]=1146, [1146]=1147, +[1148]=1184, [1149]=1150, [1150]=1151, [1151]=1152, [1152]=1154, [1154]=6627, [1159]=1160, [1160]=6628, +[1167]=1143, [1170]=1171, [1171]=1172, [1176]=1178, [1178]=1180, [1180]=1181, [1181]=1182, [1182]=1183, +[1183]=1186, [1186]=1187, [1187]=1188, [1188]=1189, [1190]=1194, [1195]=1196, [1196]=1197, [1198]=1200, +[1201]=1202, [1204]=1258, [1206]=1203, [1218]=1206, [1219]=1220, [1239]=1240, [1241]=1242, [1242]=1243, +[1243]=1244, [1244]=1245, [1245]=1246, [1246]=1447, [1247]=1248, [1248]=1249, [1250]=1264, [1251]=1321, +[1252]=1259, [1253]=1319, [1259]=1285, [1260]=1204, [1261]=1262, [1264]=1265, [1265]=1266, [1266]=1324, +[1274]=1241, [1285]=1286, [1286]=1287, [1288]=1289, [1289]=1390, [1301]=1302, [1319]=1320, [1321]=1322, +[1322]=1323, [1339]=1338, [1359]=1358, [1361]=1362, [1363]=1364, [1367]=1369, [1368]=1370, [1369]=1371, +[1370]=1373, [1372]=1383, [1373]=1374, [1374]=1380, [1375]=1381, [1382]=1384, [1383]=1388, [1384]=1370, +[1385]=1386, [1386]=1369, [1388]=1391, [1396]=1421, [1398]=1425, [1418]=1420, [1421]=1398, [1422]=1426, +[1424]=1429, [1426]=1427, [1427]=1428, [1429]=1444, [1431]=1432, [1433]=1435, [1437]=1465, [1438]=1439, +[1444]=1445, [1447]=1247, [1448]=1449, [1449]=1450, [1450]=1451, [1451]=1452, [1454]=1455, [1455]=1456, +[1456]=1457, [1458]=1459, [1459]=1466, [1465]=1438, [1466]=1467, [1469]=1475, [1472]=1476, [1473]=1471, +[1476]=1474, [1477]=1395, [1478]=1473, [1480]=1481, [1481]=1482, [1483]=1093, [1485]=1499, [1489]=1490, +[1490]=914, [1498]=1502, [1499]=794, [1501]=1504, [1502]=1503, [1505]=1498, [1506]=1501, [1507]=1508, +[1508]=1509, [1509]=1510, [1510]=1511, [1511]=1515, [1512]=1513, [1515]=1512, [1516]=1517, [1517]=1518, +[1519]=1520, [1520]=1521, [1522]=1524, [1523]=1524, [1524]=1525, [1525]=1526, [1526]=1527, [1528]=1530, +[1529]=1530, [1530]=1535, [1534]=220, [1535]=1536, [1536]=1534, [1579]=1580, [1638]=1639, [1639]=1640, +[1640]=1665, [1642]=1643, [1643]=1644, [1644]=1780, [1646]=1647, [1647]=1648, [1648]=1778, [1649]=1650, +[1651]=1652, [1652]=1653, [1653]=1654, [1654]=1806, [1666]=1667, [1678]=1680, [1679]=1678, [1680]=1681, +[1681]=1682, [1684]=1683, [1685]=1688, [1686]=1692, [1688]=1689, [1690]=1691, [1698]=1699, [1700]=1705, +[1702]=1701, [1703]=1710, [1704]=1708, [1712]=1713, [1716]=1738, [1717]=1716, [1718]=1719, [1738]=1739, +[1758]=1802, [1778]=1779, [1779]=1783, [1780]=1781, [1781]=1786, [1783]=1784, [1784]=1785, [1786]=1787, +[1787]=1788, [1791]=1712, [1796]=4781, [1798]=1758, [1799]=4961, [1801]=1803, [1802]=1804, [1803]=1805, +[1804]=1795, [1805]=1795, [1818]=1819, [1819]=1820, [1820]=1821, [1823]=1824, [1824]=1825, [1825]=1838, +[1839]=1842, [1840]=1844, [1841]=1846, [1859]=1963, [1860]=1861, [1879]=1880, [1881]=1882, [1883]=1884, +[1885]=1886, [1886]=1898, [1898]=1899, [1899]=1978, [1918]=824, [1919]=1920, [1920]=1921, [1938]=1940, +[1939]=1938, [1943]=1944, [1944]=1945, [1949]=1950, [1950]=1951, [1953]=1954, [1954]=1955, [1955]=1956, +[1956]=1957, [1959]=1960, [1960]=1961, [1963]=1858, [1998]=1999, [2039]=2038, [2041]=2040, [2098]=2078, +[2118]=2138, [2198]=2199, [2199]=2200, [2200]=2201, [2201]=2204, [2218]=2238, [2238]=2239, [2258]=2202, +[2259]=2260, [2279]=2439, [2281]=2282, [2283]=2284, [2284]=2318, [2299]=2298, [2300]=2281, [2318]=2338, +[2340]=2341, [2359]=2607, [2360]=2359, [2378]=2379, [2379]=2382, [2380]=2379, [2382]=2381, [2398]=2240, +[2438]=2459, [2460]=2458, [2478]=2479, [2479]=2480, [2498]=2499, [2518]=2520, [2519]=2518, [2541]=2561, +[2605]=2606, [2606]=2641, [2621]=2622, [2622]=2623, [2661]=2662, [2681]=2702, [2702]=2701, [2721]=2743, +[2742]=2782, [2743]=2744, [2744]=3141, [2745]=2746, [2757]=2760, [2758]=2759, [2759]=2760, [2766]=2767, +[2769]=2770, [2783]=2801, [2784]=2621, [2801]=2681, [2842]=2843, [2844]=2845, [2861]=2846, [2862]=2863, +[2863]=2902, [2864]=2865, [2866]=2867, [2869]=2870, [2870]=2871, [2872]=2873, [2873]=2874, [2879]=2942, +[2902]=2903, [2923]=2922, [2925]=2924, [2926]=2962, [2927]=2926, [2931]=2930, [2933]=2934, [2934]=2935, +[2935]=2936, [2936]=2937, [2940]=2941, [2941]=2944, [2944]=2943, [2947]=2948, [2949]=2950, [2963]=2946, +[2965]=2966, [2967]=2968, [2970]=2972, [2972]=3841, [2973]=2974, [2974]=2976, [2975]=2980, [2977]=2964, +[2978]=2979, [2979]=3002, [2981]=2975, [2983]=1524, [2984]=1524, [2985]=1530, [2986]=1530, [2988]=2989, +[2989]=2990, [2990]=2991, [2991]=2992, [2992]=2993, [2993]=2994, [2997]=1645, [2998]=1641, [2999]=1645, +[3000]=1645, [3121]=3122, [3123]=3124, [3124]=3125, [3125]=3126, [3126]=3127, [3130]=2869, [3141]=3508, +[3181]=3182, [3221]=1359, [3367]=3368, [3368]=3371, [3371]=3372, [3377]=3378, [3379]=3385, [3380]=3444, +[3385]=3402, [3442]=3443, [3443]=3452, [3445]=3444, [3448]=3449, [3449]=3461, [3450]=3451, [3452]=3453, +[3453]=3454, [3462]=3463, [3501]=3502, [3504]=3505, [3505]=3506, [3506]=3507, [3508]=3509, [3509]=3510, +[3510]=3511, [3511]=3602, [3513]=3514, [3518]=3562, [3519]=3521, [3520]=3527, [3521]=3522, [3523]=3525, +[3524]=4681, [3527]=4787, [3541]=3563, [3542]=3564, [3561]=3565, [3578]=3579, [3579]=3580, [3580]=3581, +[3621]=3625, [3625]=3626, [3626]=3627, [3627]=3628, [3631]=4490, [3638]=3639, [3640]=3641, [3642]=3643, +[3681]=1641, [3701]=4341, [3702]=3701, [3762]=3761, [3763]=3764, [3765]=1275, [3781]=3785, [3782]=3786, +[3783]=977, [3784]=3761, [3787]=3791, [3788]=3791, [3789]=3764, [3790]=3764, [3801]=3802, [3821]=3822, +[3823]=3824, [3824]=3825, [3841]=3842, [3842]=3843, [3843]=4297, [3844]=3845, [3845]=3908, [3903]=3904, +[3904]=3905, [3906]=3907, [3909]=3912, [3912]=3913, [3913]=3914, [3914]=3941, [3921]=3922, [3923]=3924, +[3941]=3942, [3942]=4084, [3961]=3962, [3981]=3982, [3982]=4001, [4001]=4002, [4002]=4003, [4003]=4004, +[4005]=3961, [4022]=4024, [4023]=4024, [4061]=4062, [4062]=4063, [4084]=4005, [4124]=4125, [4125]=4127, +[4127]=4129, [4128]=4126, [4129]=4130, [4130]=4131, [4131]=4135, [4133]=4134, [4135]=4265, [4141]=4142, +[4142]=4143, [4145]=4147, [4147]=4146, [4182]=4183, [4183]=4184, [4184]=4185, [4185]=4186, [4186]=4223, +[4223]=4224, [4224]=4241, [4241]=4242, [4243]=4244, [4262]=4263, [4264]=4282, [4265]=4266, [4266]=4267, +[4290]=4291, [4291]=4292, [4297]=4298, [4322]=6402, [4324]=4136, [4341]=4342, [4342]=4361, [4361]=4362, +[4362]=4363, [4449]=4450, [4487]=4490, [4488]=4490, [4489]=4490, [4493]=4496, [4494]=4496, [4495]=3519, +[4508]=4510, [4509]=4511, [4521]=4741, [4542]=4841, [4641]=788, [4726]=4808, [4734]=4735, [4736]=1796, +[4737]=1796, [4738]=1796, [4739]=1796, [4741]=4721, [4742]=4743, [4761]=4762, [4764]=4765, [4766]=4764, +[4769]=4768, [4781]=4782, [4782]=4783, [4783]=4784, [4784]=4786, [4787]=3528, [4808]=4809, [4809]=4810, +[4811]=4812, [4812]=4813, [4822]=558, [4841]=5064, [4861]=4863, [4863]=4864, [4865]=5062, [4881]=4966, +[4882]=4883, [4903]=4941, [4907]=4734, [4941]=4974, [4961]=4976, [4965]=1799, [4967]=1799, [4968]=1799, +[4969]=1799, [4981]=4982, [4982]=4983, [4984]=4985, [5001]=5002, [5002]=5081, [5022]=5048, [5023]=5049, +[5048]=5050, [5049]=5050, [5050]=5051, [5054]=5055, [5055]=5056, [5065]=4788, [5066]=5092, [5083]=5084, +[5084]=5085, [5085]=5086, [5086]=5087, [5087]=5121, [5089]=5102, [5090]=5092, [5091]=5092, [5093]=5096, +[5094]=5096, [5095]=5096, [5103]=5124, [5142]=5149, [5152]=5153, [5153]=5154, [5155]=5157, [5157]=5158, +[5159]=5165, [5160]=5161, [5161]=5162, [5162]=5164, [5203]=5204, [5204]=5385, [5206]=5941, [5210]=5181, +[5212]=5213, [5215]=5216, [5216]=5217, [5217]=5219, [5219]=5220, [5220]=5222, [5222]=5223, [5223]=5225, +[5225]=5226, [5228]=5229, [5229]=5230, [5230]=5231, [5231]=5232, [5232]=5233, [5233]=5234, [5234]=5235, +[5235]=5236, [5241]=5211, [5244]=5245, [5245]=5246, [5246]=5247, [5248]=5252, [5249]=5244, [5250]=5244, +[5252]=5253, [5261]=33, [5262]=5263, [5264]=5265, [5281]=5282, [5341]=5342, [5343]=5344, [5381]=5581, +[5382]=5515, [5441]=6394, [5461]=5462, [5462]=5463, [5463]=5464, [5464]=5465, [5465]=5466, [5481]=5482, +[5514]=5802, [5515]=5384, [5522]=5531, [5527]=5526, [5531]=4771, [5533]=5537, [5537]=5538, [5538]=5801, +[5542]=5742, [5543]=5742, [5544]=5742, [5601]=5149, [5622]=5621, [5623]=5624, [5626]=5625, [5649]=5648, +[5651]=5650, [5721]=5942, [5722]=5724, [5727]=5728, [5728]=5729, [5729]=5730, [5741]=6027, [5742]=5781, +[5781]=5845, [5801]=5803, [5802]=5804, [5845]=5846, [5846]=5848, [5848]=5861, [5861]=5862, [5901]=5902, +[5902]=6390, [5903]=5904, [5904]=6389, [5921]=5929, [5922]=5930, [5923]=5921, [5924]=5921, [5925]=5921, +[5926]=5922, [5927]=5922, [5928]=5922, [5929]=5931, [5930]=5932, [5931]=6001, [5932]=6002, [5941]=5721, +[6004]=6023, [6023]=6025, [6026]=6041, [6061]=6087, [6062]=6083, [6063]=6101, [6064]=6084, [6082]=6081, +[6083]=6082, [6084]=6085, [6085]=6086, [6087]=6088, [6088]=6089, [6101]=6102, [6102]=6103, [6121]=6122, +[6122]=6123, [6123]=6124, [6124]=6125, [6126]=6127, [6127]=6128, [6128]=6129, [6129]=6130, [6141]=261, +[6144]=6145, [6145]=6146, [6146]=6147, [6147]=6148, [6181]=6281, [6182]=6183, [6183]=6184, [6184]=6185, +[6185]=6186, [6186]=6187, [6261]=6285, [6281]=6261, [6282]=6283, [6321]=6323, [6322]=6324, [6323]=6322, +[6341]=6342, [6342]=6343, [6344]=6341, [6361]=6362, [6362]=6363, [6363]=6364, [6365]=6384, [6384]=6385, +[6385]=6386, [6387]=6391, [6388]=6392, [6391]=6388, [6403]=6501, [6501]=6502, [6522]=6521, [6523]=6401, +[6541]=6543, [6542]=6543, [6548]=6629, [6562]=6563, [6564]=6565, [6566]=6567, [6567]=6568, [6568]=6569, +[6569]=6570, [6585]=6601, [6601]=6602, [6603]=5082, [6604]=4861, [6605]=4505, [6606]=969, [6611]=6610, +[6612]=6610, [6623]=6622, [6625]=6624, [6627]=1159, [6628]=1394, [6761]=6762, [6762]=1124, [6821]=6822, +[6822]=6823, [6823]=6824, [6963]=6983, [6964]=7061, [6983]=6984, [7042]=7043, [7043]=7045, [7062]=7063, +[7383]=935, [7490]=7491, [7492]=7489, [7494]=7488, [7495]=7496, [7507]=7508, [7508]=7509, [7562]=7563, +[7563]=7564, [7601]=7602, [7602]=7603, [7621]=7622, [7623]=7624, [7624]=7625, [7637]=7639, [7639]=7640, +[7641]=7642, [7643]=7644, [7644]=7646, [7646]=7647, [7667]=8258, [7781]=7782, [7783]=7784, [7785]=7786, +[7841]=7842, [7842]=7843, [7845]=7846, [7846]=7847, [8151]=8153, [8153]=8231, [8231]=8232, [8233]=8234, +[8234]=8235, [8235]=8236, [8251]=8252, [8252]=8253, [8254]=8255, [8255]=8256, [8256]=8257, [8275]=8280, +[8276]=8280, [8279]=8287, [8280]=8281, [8284]=8285, [8285]=8279, [8288]=8301, [8303]=8305, [8304]=8306, +[8305]=8519, [8307]=8313, [8313]=8317, [8314]=8315, [8320]=8321, [8322]=8409, [8331]=8332, [8332]=8349, +[8341]=8351, [8343]=8341, [8349]=8348, [8351]=8352, [8410]=8412, [8411]=8412, [8415]=8414, [8416]=8418, +[8417]=8423, [8419]=8421, [8420]=8421, [8421]=8422, [8423]=8424, [8424]=8425, [8446]=8447, [8553]=8554, +[8577]=8578, [8584]=8585, [8585]=8586, [8586]=8587, [8597]=8598, [8598]=8606, [8606]=8620, [8728]=8729, +[8733]=8734, [8734]=8735, [8801]=8802, [8867]=8883, [8870]=8867, [8871]=8867, [8872]=8867, [8873]=8867, +[8874]=8867, [8875]=8867, [8903]=9024, [8904]=8979, [8921]=8924, [8922]=8921, [8923]=8921, [8924]=8925, +[8925]=8928, [8926]=8929, [8927]=8930, [8929]=8945, [8930]=8945, [8931]=8929, [8932]=8929, [8933]=8929, +[8934]=8929, [8935]=8929, [8936]=8929, [8937]=8929, [8938]=8930, [8939]=8930, [8940]=8930, [8941]=8930, +[8942]=8930, [8943]=8930, [8944]=8930, [8945]=8946, [8946]=8947, [8947]=8948, [8948]=8949, [8949]=8950, +[8951]=8960, [8952]=8960, [8953]=8960, [8954]=8960, [8955]=8960, [8956]=8960, [8957]=9032, [8958]=8960, +[8959]=8960, [8960]=8961, [8962]=8966, [8963]=8967, [8964]=8968, [8965]=8969, [8966]=8970, [8967]=8970, +[8968]=8970, [8969]=8970, [8979]=8980, [8980]=8982, [8982]=8983, [8983]=8984, [8985]=8990, [8986]=8989, +[8987]=8991, [8988]=8992, [8989]=8994, [8990]=8994, [8991]=8994, [8992]=8994, [8994]=8995, [8995]=8996, +[9016]=9032, [9017]=9032, [9018]=9032, [9019]=9032, [9020]=9032, [9021]=9032, [9022]=9032, [9024]=9025, +[9025]=9026, [9026]=9027, [9027]=9028, [9032]=8961, [9051]=9053, [9052]=9051, [9063]=9052, [9229]=9230, +[9250]=9251, [9362]=9364, [9415]=9419, [9416]=9422, [50003]=50004, +} +Codex.WdbZone = { +[2]=331, [5]=10, [6]=9, [7]=9, [8]=154, [9]=40, [10]=440, [11]=12, +[12]=40, [13]=40, [14]=40, [15]=9, [16]=12, [17]=1517, [18]=9, [19]=44, +[20]=44, [21]=9, [22]=40, [23]=331, [24]=331, [25]=331, [26]=-263, [27]=-263, +[28]=-263, [29]=-263, [30]=-263, [31]=-263, [32]=440, [33]=9, [34]=44, [35]=12, +[36]=40, [37]=12, [38]=40, [39]=12, [40]=12, [45]=12, [46]=12, [47]=12, +[48]=40, [49]=40, [50]=40, [51]=40, [52]=12, [53]=40, [54]=9, [55]=10, +[56]=10, [57]=10, [58]=10, [59]=12, [60]=12, [61]=12, [62]=12, [63]=-82, +[64]=40, [65]=40, [66]=10, [67]=10, [68]=10, [69]=10, [70]=10, [71]=12, +[72]=10, [74]=10, [75]=10, [76]=12, [77]=47, [78]=10, [79]=10, [80]=10, +[81]=1637, [82]=440, [83]=12, [84]=12, [85]=12, [86]=12, [87]=12, [88]=12, +[89]=44, [90]=10, [91]=44, [92]=44, [93]=10, [94]=44, [95]=10, [96]=-82, +[97]=10, [98]=10, [99]=130, [100]=-82, [101]=10, [102]=40, [103]=40, [104]=40, +[105]=28, [106]=12, [107]=12, [109]=12, [110]=440, [111]=12, [112]=12, [113]=440, +[114]=12, [115]=44, [116]=44, [117]=40, [118]=44, [119]=44, [120]=44, [121]=44, +[122]=44, [123]=12, [124]=44, [125]=44, [126]=44, [127]=44, [128]=44, [129]=44, +[130]=44, [131]=44, [132]=40, [133]=10, [134]=10, [135]=40, [136]=40, [138]=40, +[139]=40, [140]=40, [141]=40, [142]=40, [143]=44, [144]=44, [145]=44, [146]=44, +[147]=12, [148]=10, [149]=10, [150]=44, [151]=40, [152]=40, [153]=40, [154]=10, +[155]=40, [156]=10, [157]=10, [158]=10, [159]=10, [160]=10, [161]=38, [162]=440, +[163]=10, [164]=10, [165]=10, [166]=1581, [167]=1581, [168]=1581, [169]=44, [170]=132, +[171]=-284, [172]=-284, [173]=10, [174]=10, [175]=10, [176]=12, [177]=10, [178]=44, +[179]=132, [180]=44, [181]=10, [182]=132, [183]=132, [184]=40, [185]=33, [186]=33, +[187]=33, [188]=33, [189]=33, [190]=33, [191]=33, [192]=33, [193]=33, [194]=33, +[195]=33, [196]=33, [197]=33, [198]=33, [199]=38, [200]=33, [201]=33, [202]=33, +[203]=33, [204]=33, [205]=33, [206]=33, [207]=33, [208]=33, [209]=33, [210]=33, +[211]=28, [212]=1519, [213]=33, [214]=1581, [215]=33, [216]=331, [217]=38, [218]=132, +[219]=44, [220]=-82, [221]=10, [222]=10, [223]=10, [224]=38, [225]=10, [226]=10, +[227]=10, [228]=10, [229]=10, [230]=10, [231]=10, [232]=1497, [233]=132, [234]=132, +[235]=1637, [236]=10, [237]=38, [238]=1497, [239]=12, [240]=10, [243]=440, [244]=44, +[245]=10, [246]=44, [247]=331, [248]=44, [249]=44, [250]=38, [251]=10, [252]=10, +[253]=10, [254]=10, [255]=38, [256]=38, [257]=38, [258]=38, [261]=405, [262]=10, +[263]=38, [264]=1638, [265]=10, [266]=10, [267]=38, [268]=10, [269]=10, [270]=10, +[271]=38, [272]=-263, [273]=38, [274]=38, [275]=11, [276]=11, [277]=11, [278]=38, +[279]=11, [280]=38, [281]=11, [282]=132, [283]=38, [284]=11, [285]=11, [286]=11, +[287]=1, [288]=11, [289]=11, [290]=11, [291]=1, [292]=11, [293]=11, [294]=11, +[295]=11, [296]=11, [297]=38, [298]=38, [299]=11, [301]=38, [302]=38, [303]=11, +[304]=11, [305]=11, [306]=11, [307]=38, [308]=1, [309]=38, [310]=1, [311]=1, +[312]=1, [313]=1, [314]=1, [315]=1, [317]=1, [318]=1, [319]=1, [320]=1, +[321]=10, [322]=10, [323]=10, [324]=10, [325]=10, [328]=33, [329]=33, [330]=33, +[331]=33, [332]=1519, [333]=1519, [334]=1519, [335]=1519, [336]=1519, [337]=10, [338]=33, +[339]=33, [340]=33, [341]=33, [342]=33, [343]=1519, [344]=1519, [345]=1519, [346]=1519, +[347]=1519, [348]=33, [349]=33, [350]=1519, [351]=440, [353]=1519, [354]=85, [355]=85, +[356]=85, [357]=1497, [358]=85, [359]=85, [360]=85, [361]=85, [362]=85, [363]=154, +[364]=154, [365]=85, [366]=85, [367]=85, [368]=85, [369]=85, [370]=85, [371]=85, +[372]=85, [373]=1519, [374]=85, [375]=85, [376]=154, [377]=717, [378]=717, [379]=440, +[380]=154, [381]=154, [382]=154, [383]=154, [384]=-304, [385]=38, [386]=717, [387]=717, +[388]=717, [389]=1519, [391]=717, [392]=1519, [393]=1519, [394]=1519, [395]=1519, [396]=1519, +[397]=1519, [398]=85, [399]=1519, [400]=1, [401]=10, [403]=1, [404]=85, [405]=85, +[407]=85, [408]=85, [409]=85, [410]=85, [411]=85, [412]=1, [413]=1, [414]=1, +[415]=1, [416]=38, [417]=1, [418]=38, [419]=1, [420]=132, [421]=130, [422]=130, +[423]=130, [424]=130, [425]=130, [426]=85, [427]=85, [428]=130, [429]=130, [430]=130, +[431]=85, [432]=1, [433]=1, [434]=1519, [435]=130, [436]=38, [437]=130, [438]=130, +[439]=130, [440]=130, [441]=130, [442]=130, [443]=130, [444]=130, [445]=85, [446]=130, +[447]=130, [448]=130, [449]=130, [450]=130, [451]=130, [452]=130, [453]=10, [454]=38, +[455]=11, [456]=188, [457]=188, [458]=188, [459]=188, [460]=130, [461]=130, [463]=11, +[464]=11, [465]=11, [466]=1, [467]=1, [468]=11, [469]=11, [470]=11, [471]=11, +[472]=11, [473]=11, [474]=11, [475]=141, [476]=141, [477]=130, [478]=130, [479]=130, +[480]=130, [481]=130, [482]=130, [483]=141, [484]=11, [485]=47, [486]=141, [487]=141, +[488]=141, [489]=141, [491]=130, [492]=85, [493]=130, [494]=267, [495]=1497, [496]=267, +[498]=267, [499]=267, [500]=36, [501]=267, [502]=267, [503]=267, [504]=36, [505]=36, +[506]=267, [507]=267, [508]=267, [509]=267, [510]=36, [511]=36, [512]=36, [513]=267, +[514]=36, [515]=267, [516]=130, [517]=267, [518]=267, [519]=267, [520]=267, [521]=267, +[522]=36, [523]=36, [524]=267, [525]=36, [526]=10, [527]=267, [528]=267, [529]=267, +[530]=130, [531]=38, [532]=267, [533]=267, [535]=36, [536]=267, [537]=36, [538]=1519, +[539]=267, [540]=1519, [541]=267, [542]=1519, [543]=1519, [544]=267, [545]=267, [546]=267, +[547]=267, [549]=267, [550]=267, [551]=36, [552]=267, [553]=267, [554]=36, [555]=267, +[556]=267, [557]=267, [558]=-284, [559]=267, [560]=267, [561]=267, [562]=267, [563]=267, +[564]=267, [565]=267, [566]=267, [567]=267, [568]=33, [569]=33, [570]=33, [571]=33, +[572]=33, [573]=33, [574]=33, [575]=33, [576]=33, [577]=33, [578]=33, [579]=1519, +[580]=33, [581]=33, [582]=33, [583]=33, [584]=33, [585]=33, [586]=33, [587]=33, +[588]=33, [589]=33, [590]=154, [591]=33, [592]=33, [593]=33, [594]=33, [595]=33, +[596]=33, [597]=33, [598]=33, [599]=33, [600]=33, [601]=33, [602]=33, [603]=33, +[604]=33, [605]=33, [606]=33, [607]=33, [608]=33, [609]=33, [610]=33, [611]=33, +[613]=33, [614]=33, [615]=33, [616]=33, [617]=33, [618]=33, [619]=33, [621]=33, +[622]=33, [623]=33, [624]=33, [625]=33, [626]=33, [627]=33, [628]=33, [629]=33, +[630]=33, [631]=11, [632]=11, [633]=11, [634]=11, [635]=45, [636]=45, [637]=1537, +[638]=33, [639]=45, [640]=45, [641]=45, [642]=45, [643]=45, [644]=45, [645]=45, +[646]=45, [647]=11, [648]=440, [649]=1637, [650]=1637, [651]=45, [652]=45, [653]=45, +[654]=440, [655]=45, [656]=45, [657]=267, [658]=267, [659]=267, [660]=267, [661]=267, +[662]=45, [663]=45, [664]=45, [665]=45, [666]=45, [667]=45, [668]=45, [669]=45, +[670]=45, [671]=45, [672]=45, [673]=45, [674]=45, [675]=45, [676]=267, [677]=45, +[678]=45, [679]=45, [680]=45, [681]=45, [682]=45, [683]=1537, [684]=45, [685]=45, +[686]=1537, [687]=45, [688]=45, [689]=1537, [690]=1519, [691]=45, [692]=3, [693]=45, +[694]=45, [695]=45, [696]=45, [697]=45, [698]=8, [699]=8, [700]=1537, [701]=45, +[702]=45, [703]=3, [704]=1517, [705]=3, [706]=3, [707]=1537, [708]=3, [709]=1517, +[710]=3, [711]=3, [712]=3, [713]=3, [714]=3, [715]=3, [716]=3, [717]=3, +[718]=3, [719]=3, [720]=3, [721]=1517, [722]=1517, [723]=3, [724]=3, [725]=3, +[726]=3, [727]=3, [728]=3, [729]=148, [730]=1657, [731]=148, [732]=3, [733]=3, +[734]=3, [735]=3, [736]=1497, [737]=3, [738]=3, [739]=3, [741]=148, [742]=1638, +[743]=215, [744]=1638, [745]=215, [746]=215, [747]=220, [748]=215, [749]=215, [750]=220, +[751]=215, [752]=220, [753]=220, [754]=215, [755]=220, [756]=215, [757]=220, [758]=215, +[759]=215, [760]=215, [804]=363, [805]=363, [806]=14, [807]=14, [808]=14, [809]=14, +[810]=14, [811]=14, [812]=14, [813]=14, [814]=14, [815]=14, [816]=14, [817]=14, +[818]=14, [819]=17, [820]=14, [821]=17, [822]=17, [823]=14, [824]=331, [825]=14, +[826]=14, [827]=14, [828]=14, [829]=14, [830]=14, [831]=14, [832]=14, [833]=215, +[834]=14, [835]=14, [836]=47, [837]=14, [838]=28, [840]=14, [841]=440, [842]=14, +[843]=17, [844]=17, [845]=17, [846]=17, [847]=45, [848]=17, [849]=17, [850]=17, +[851]=17, [852]=17, [853]=17, [854]=215, [855]=17, [857]=17, [858]=17, [860]=17, +[861]=215, [862]=-304, [863]=17, [864]=1497, [865]=17, [866]=-24, [867]=17, [868]=17, +[869]=17, [870]=17, [871]=17, [872]=17, [873]=17, [874]=17, [875]=17, [876]=17, +[877]=17, [878]=17, [879]=17, [880]=17, [881]=17, [882]=17, [883]=17, [884]=17, +[885]=17, [886]=17, [887]=17, [888]=17, [889]=17, [890]=17, [891]=17, [892]=17, +[893]=17, [894]=17, [895]=17, [896]=17, [897]=17, [898]=17, [899]=17, [900]=17, +[901]=17, [902]=17, [903]=17, [905]=17, [906]=17, [907]=17, [908]=331, [909]=331, +[910]=-284, [911]=-284, [913]=17, [914]=718, [915]=-284, [916]=188, [917]=188, [918]=141, +[919]=141, [920]=188, [921]=188, [922]=141, [923]=141, [924]=14, [925]=-284, [926]=14, +[927]=141, [928]=188, [929]=141, [930]=141, [931]=141, [932]=141, [933]=141, [934]=141, +[935]=141, [936]=1637, [937]=141, [938]=141, [939]=361, [940]=141, [941]=141, [942]=148, +[943]=148, [944]=148, [945]=148, [947]=148, [948]=148, [949]=148, [950]=148, [951]=148, +[952]=1657, [953]=148, [954]=148, [955]=148, [956]=148, [957]=148, [958]=148, [959]=718, +[960]=148, [961]=148, [962]=718, [963]=148, [964]=28, [965]=148, [966]=148, [967]=148, +[968]=148, [969]=618, [970]=148, [971]=719, [972]=-82, [973]=148, [974]=490, [975]=618, +[976]=331, [977]=618, [978]=702, [979]=702, [980]=490, [981]=148, [982]=148, [983]=148, +[984]=148, [985]=148, [986]=148, [990]=331, [991]=331, [992]=440, [993]=148, [994]=148, +[995]=148, [996]=361, [997]=141, [998]=361, [1000]=1638, [1001]=148, [1002]=148, [1003]=148, +[1004]=1497, [1007]=331, [1008]=331, [1009]=331, [1010]=331, [1011]=331, [1012]=331, [1013]=209, +[1014]=209, [1015]=1519, [1016]=331, [1017]=331, [1018]=1637, [1019]=1537, [1020]=331, [1021]=331, +[1022]=331, [1023]=331, [1024]=331, [1025]=331, [1026]=331, [1027]=331, [1028]=331, [1029]=331, +[1030]=331, [1031]=331, [1032]=331, [1033]=331, [1034]=331, [1035]=331, [1036]=33, [1037]=331, +[1038]=331, [1039]=331, [1040]=331, [1041]=331, [1042]=331, [1043]=331, [1044]=331, [1045]=331, +[1046]=331, [1047]=1657, [1048]=796, [1049]=796, [1050]=796, [1051]=796, [1052]=405, [1053]=796, +[1054]=331, [1055]=331, [1056]=331, [1057]=406, [1058]=406, [1059]=406, [1060]=17, [1061]=406, +[1062]=406, [1063]=406, [1064]=1638, [1065]=1638, [1066]=1638, [1067]=1638, [1068]=406, [1069]=17, +[1070]=331, [1071]=406, [1072]=406, [1073]=406, [1074]=406, [1075]=406, [1076]=40, [1077]=406, +[1078]=406, [1079]=406, [1080]=406, [1081]=406, [1082]=406, [1083]=406, [1084]=406, [1085]=331, +[1086]=1638, [1087]=406, [1088]=406, [1089]=406, [1090]=406, [1091]=406, [1092]=406, [1093]=406, +[1094]=406, [1095]=406, [1096]=406, [1097]=1519, [1098]=209, [1100]=400, [1101]=1717, [1102]=1717, +[1103]=-82, [1104]=400, [1105]=400, [1106]=400, [1107]=400, [1108]=400, [1109]=1717, [1110]=400, +[1111]=400, [1112]=400, [1113]=796, [1114]=400, [1115]=400, [1116]=400, [1117]=400, [1118]=400, +[1119]=400, [1120]=400, [1121]=400, [1122]=400, [1123]=1638, [1124]=493, [1125]=1377, [1126]=1377, +[1127]=33, [1130]=1638, [1131]=1638, [1132]=11, [1133]=15, [1134]=331, [1135]=15, [1136]=1638, +[1137]=400, [1138]=148, [1139]=1517, [1140]=148, [1141]=148, [1142]=1717, [1143]=148, [1144]=1717, +[1145]=17, [1146]=1637, [1147]=400, [1148]=400, [1149]=400, [1150]=400, [1151]=400, [1152]=400, +[1153]=17, [1154]=400, [1157]=440, [1158]=440, [1159]=400, [1160]=796, [1164]=1497, [1166]=15, +[1167]=148, [1168]=15, [1169]=15, [1170]=15, [1171]=15, [1172]=15, [1173]=15, [1175]=400, +[1176]=400, [1177]=15, [1178]=400, [1179]=400, [1180]=400, [1181]=400, [1182]=400, [1183]=400, +[1184]=400, [1185]=493, [1186]=400, [1187]=400, [1188]=400, [1189]=400, [1190]=400, [1191]=400, +[1192]=400, [1193]=2557, [1194]=400, [1195]=1638, [1196]=1638, [1197]=1638, [1198]=719, [1199]=719, +[1200]=719, [1201]=15, [1202]=15, [1203]=15, [1204]=15, [1205]=15, [1206]=15, [1218]=15, +[1219]=15, [1220]=15, [1221]=1717, [1222]=15, [1238]=15, [1239]=15, [1240]=15, [1241]=1519, +[1242]=1519, [1243]=1519, [1244]=1519, [1245]=1519, [1246]=1519, [1247]=1519, [1248]=1519, [1249]=1519, +[1250]=1519, [1251]=15, [1252]=15, [1253]=15, [1258]=15, [1259]=15, [1260]=15, [1261]=15, +[1262]=15, [1264]=1519, [1265]=1519, [1266]=1519, [1267]=1519, [1268]=15, [1269]=15, [1270]=15, +[1271]=15, [1273]=15, [1274]=1519, [1275]=719, [1276]=15, [1282]=15, [1284]=15, [1285]=15, +[1286]=15, [1287]=15, [1288]=15, [1289]=15, [1301]=1519, [1302]=11, [1318]=2557, [1319]=15, +[1320]=15, [1321]=15, [1322]=15, [1323]=15, [1324]=1519, [1338]=38, [1339]=38, [1358]=1497, +[1359]=1497, [1360]=1517, [1361]=405, [1362]=405, [1363]=1519, [1364]=1519, [1365]=405, [1366]=405, +[1367]=405, [1368]=405, [1369]=405, [1370]=405, [1371]=405, [1372]=10, [1373]=405, [1374]=405, +[1375]=405, [1380]=405, [1381]=405, [1382]=405, [1383]=10, [1384]=405, [1385]=405, [1386]=405, +[1387]=405, [1388]=10, [1389]=8, [1390]=15, [1391]=10, [1392]=8, [1393]=8, [1394]=400, +[1395]=10, [1396]=8, [1398]=8, [1418]=8, [1419]=3, [1420]=3, [1421]=8, [1422]=8, +[1423]=8, [1424]=8, [1425]=8, [1426]=8, [1427]=8, [1428]=8, [1429]=8, [1430]=8, +[1431]=1637, [1432]=1637, [1433]=1637, [1434]=1637, [1435]=1637, [1436]=1637, [1437]=405, [1438]=405, +[1439]=405, [1440]=405, [1442]=-141, [1444]=47, [1445]=1417, [1446]=1417, [1447]=1519, [1448]=1519, +[1449]=1519, [1450]=47, [1451]=47, [1452]=47, [1453]=1537, [1454]=405, [1455]=405, [1456]=405, +[1457]=405, [1458]=405, [1459]=405, [1462]=-82, [1463]=-82, [1464]=-82, [1465]=405, [1466]=405, +[1467]=405, [1468]=-284, [1469]=47, [1470]=-61, [1471]=-61, [1472]=-61, [1473]=-61, [1474]=-61, +[1475]=1417, [1476]=-61, [1477]=1519, [1478]=-61, [1479]=-284, [1480]=405, [1481]=405, [1482]=405, +[1483]=17, [1484]=405, [1485]=-61, [1486]=718, [1487]=718, [1488]=405, [1489]=718, [1490]=718, +[1491]=718, [1492]=17, [1498]=-81, [1499]=-61, [1501]=-61, [1502]=-81, [1503]=-81, [1504]=-61, +[1505]=-81, [1506]=-61, [1507]=-61, [1508]=-61, [1509]=-61, [1510]=-61, [1511]=-61, [1512]=-61, +[1513]=-61, [1514]=361, [1515]=-61, [1516]=-82, [1517]=-82, [1518]=-82, [1519]=-82, [1520]=-82, +[1521]=-82, [1522]=-82, [1523]=-82, [1524]=-82, [1525]=-82, [1526]=-82, [1527]=-82, [1528]=-82, +[1529]=-82, [1530]=-82, [1531]=-82, [1532]=-82, [1534]=-82, [1535]=-82, [1536]=-82, [1558]=-284, +[1559]=-201, [1560]=440, [1578]=-121, [1579]=-101, [1580]=-101, [1581]=-181, [1582]=-182, [1638]=-81, +[1639]=-81, [1640]=-81, [1641]=-141, [1642]=-141, [1643]=-141, [1644]=-141, [1645]=-141, [1646]=-141, +[1647]=-141, [1648]=-141, [1649]=-141, [1650]=-141, [1651]=-141, [1652]=-141, [1653]=-141, [1654]=-141, +[1655]=-141, [1656]=220, [1657]=-22, [1658]=-22, [1661]=-141, [1665]=-81, [1666]=-81, [1667]=-81, +[1678]=-81, [1679]=-81, [1680]=-81, [1681]=-81, [1682]=-81, [1683]=-81, [1684]=-81, [1685]=-61, +[1686]=-81, [1687]=-284, [1688]=-61, [1689]=-61, [1690]=440, [1691]=440, [1692]=-81, [1693]=-81, +[1698]=-81, [1699]=-81, [1700]=-81, [1701]=-81, [1702]=-81, [1703]=-81, [1704]=-81, [1705]=-81, +[1706]=-81, [1707]=440, [1708]=-81, [1709]=-81, [1710]=-81, [1711]=-81, [1712]=-81, [1713]=-81, +[1714]=-81, [1715]=-61, [1716]=-61, [1717]=-61, [1718]=-81, [1719]=-81, [1738]=-61, [1739]=-61, +[1740]=-61, [1758]=-61, [1778]=-141, [1779]=-141, [1780]=-141, [1781]=-141, [1783]=-141, [1784]=-141, +[1785]=-141, [1786]=-141, [1787]=-141, [1788]=-141, [1789]=-141, [1790]=-141, [1791]=-81, [1792]=-81, +[1793]=-141, [1794]=-141, [1795]=-61, [1796]=-61, [1798]=-61, [1799]=-61, [1800]=-284, [1801]=-61, +[1802]=-61, [1803]=-61, [1804]=-61, [1805]=-61, [1806]=-141, [1818]=-81, [1819]=-81, [1820]=-81, +[1821]=-81, [1822]=-81, [1823]=-81, [1824]=-81, [1825]=-81, [1838]=-81, [1839]=-81, [1840]=-81, +[1841]=-81, [1842]=-81, [1843]=-81, [1844]=-81, [1845]=-81, [1846]=-81, [1847]=-81, [1848]=-81, +[1858]=-162, [1859]=-162, [1860]=-161, [1861]=-161, [1878]=440, [1879]=-161, [1880]=-161, [1881]=-161, +[1882]=-161, [1883]=-161, [1884]=-161, [1885]=-162, [1886]=-162, [1898]=-162, [1899]=-162, [1918]=331, +[1919]=-161, [1920]=-161, [1921]=-161, [1938]=-161, [1939]=-161, [1940]=-161, [1941]=-161, [1942]=-161, +[1943]=-161, [1944]=-161, [1945]=-161, [1946]=-161, [1947]=-161, [1948]=-161, [1949]=-161, [1950]=-161, +[1951]=-161, [1952]=-161, [1953]=-161, [1954]=-161, [1955]=-161, [1956]=-161, [1957]=-161, [1958]=-161, +[1959]=-161, [1960]=-161, [1961]=-161, [1962]=-161, [1963]=-162, [1978]=-162, [1998]=-162, [1999]=-162, +[2038]=38, [2039]=1537, [2040]=1581, [2041]=1537, [2078]=148, [2098]=148, [2118]=148, [2138]=148, +[2139]=148, [2158]=12, [2159]=141, [2160]=1, [2161]=14, [2178]=-304, [2198]=1517, [2199]=1517, +[2200]=1517, [2201]=1517, [2202]=1517, [2203]=3, [2204]=1517, [2205]=-162, [2206]=-162, [2218]=-162, +[2238]=-162, [2239]=-162, [2240]=1517, [2241]=-162, [2242]=-162, [2258]=3, [2259]=-162, [2260]=-162, +[2278]=1517, [2279]=1517, [2280]=1517, [2281]=-162, [2282]=-162, [2283]=1517, [2284]=1517, [2298]=-162, +[2299]=-162, [2300]=-162, [2318]=1517, [2338]=1517, [2339]=1517, [2340]=1517, [2341]=1517, [2342]=1517, +[2358]=-162, [2359]=-162, [2360]=-162, [2361]=1517, [2378]=-162, [2379]=-162, [2380]=-162, [2381]=-162, +[2382]=-162, [2383]=-81, [2398]=1517, [2399]=141, [2418]=1517, [2438]=141, [2439]=1537, [2440]=1638, +[2458]=-162, [2459]=141, [2460]=-162, [2478]=-162, [2479]=-162, [2480]=-162, [2498]=141, [2499]=141, +[2500]=38, [2501]=38, [2518]=1657, [2519]=1657, [2520]=1657, [2521]=4, [2522]=4, [2523]=361, +[2541]=141, [2561]=141, [2581]=4, [2582]=4, [2583]=4, [2584]=4, [2585]=4, [2586]=4, +[2601]=4, [2602]=4, [2603]=4, [2604]=4, [2605]=440, [2606]=440, [2607]=-162, [2608]=-162, +[2609]=-162, [2621]=4, [2622]=8, [2623]=8, [2641]=440, [2661]=440, [2662]=440, [2681]=4, +[2701]=4, [2702]=4, [2721]=4, [2741]=440, [2742]=47, [2743]=4, [2744]=4, [2745]=1519, +[2746]=1519, [2747]=440, [2748]=440, [2749]=440, [2750]=440, [2751]=-121, [2752]=-121, [2753]=-121, +[2754]=-121, [2755]=-121, [2756]=-121, [2757]=-121, [2758]=-121, [2759]=-121, [2760]=-121, [2761]=-121, +[2762]=-121, [2763]=-121, [2764]=-121, [2765]=-121, [2766]=357, [2767]=357, [2768]=978, [2769]=400, +[2770]=978, [2771]=-121, [2772]=-121, [2773]=-121, [2781]=440, [2782]=47, [2783]=4, [2784]=4, +[2801]=4, [2821]=357, [2822]=357, [2841]=133, [2842]=133, [2843]=133, [2844]=357, [2845]=357, +[2846]=978, [2847]=-182, [2848]=-182, [2849]=-182, [2850]=-182, [2851]=-182, [2852]=-182, [2853]=-182, +[2854]=-182, [2855]=-182, [2856]=-182, [2857]=-182, [2858]=-182, [2859]=-182, [2860]=-182, [2861]=978, +[2862]=357, [2863]=357, [2864]=978, [2865]=978, [2866]=357, [2867]=357, [2869]=357, [2870]=357, +[2871]=357, [2872]=440, [2873]=440, [2874]=440, [2875]=440, [2876]=440, [2877]=47, [2878]=357, +[2879]=357, [2880]=47, [2881]=47, [2882]=-221, [2902]=357, [2903]=357, [2904]=133, [2922]=133, +[2923]=133, [2924]=133, [2925]=133, [2926]=133, [2927]=133, [2928]=133, [2929]=133, [2930]=133, +[2931]=133, [2932]=47, [2933]=47, [2934]=47, [2935]=47, [2936]=978, [2937]=47, [2938]=47, +[2939]=357, [2940]=357, [2941]=357, [2942]=357, [2943]=357, [2944]=357, [2945]=133, [2946]=1537, +[2947]=133, [2948]=1537, [2949]=133, [2950]=1637, [2951]=133, [2952]=133, [2953]=133, [2954]=440, +[2962]=133, [2963]=1537, [2964]=1537, [2965]=1638, [2966]=1638, [2967]=1638, [2968]=1638, [2969]=357, +[2970]=357, [2972]=357, [2973]=357, [2974]=357, [2975]=357, [2976]=357, [2977]=1537, [2978]=357, +[2979]=357, [2980]=357, [2981]=1637, [2982]=357, [2983]=-82, [2984]=-82, [2985]=-82, [2986]=-82, +[2987]=357, [2988]=47, [2989]=47, [2990]=47, [2991]=978, [2992]=47, [2993]=47, [2994]=47, +[2995]=1497, [2996]=-61, [2997]=-141, [2998]=-141, [2999]=-141, [3000]=-141, [3001]=-61, [3002]=357, +[3022]=440, [3042]=978, [3062]=357, [3063]=357, [3065]=-81, [3082]=-261, [3083]=-162, [3084]=-82, +[3085]=-262, [3086]=-161, [3087]=-261, [3088]=-162, [3089]=-82, [3090]=-61, [3091]=-81, [3092]=-261, +[3093]=-82, [3094]=-263, [3095]=-81, [3096]=-162, [3097]=-262, [3098]=-161, [3099]=-61, [3100]=-81, +[3101]=-141, [3102]=-162, [3103]=-262, [3104]=-161, [3105]=-61, [3106]=-81, [3107]=-141, [3108]=-261, +[3109]=-162, [3110]=-262, [3111]=-161, [3112]=-81, [3113]=-162, [3114]=-161, [3115]=-61, [3116]=-81, +[3117]=-261, [3118]=-162, [3119]=-262, [3120]=-263, [3121]=357, [3122]=357, [3123]=357, [3124]=357, +[3125]=357, [3126]=357, [3127]=357, [3128]=357, [3129]=357, [3130]=357, [3141]=16, [3161]=440, +[3181]=51, [3182]=38, [3201]=1537, [3221]=130, [3281]=17, [3301]=17, [3321]=-121, [3341]=722, +[3361]=132, [3362]=440, [3363]=361, [3364]=132, [3365]=132, [3366]=718, [3367]=51, [3368]=51, +[3369]=17, [3370]=17, [3371]=1537, [3372]=51, [3373]=1417, [3374]=8, [3375]=1517, [3376]=220, +[3377]=51, [3378]=51, [3379]=-264, [3380]=1417, [3381]=16, [3382]=16, [3385]=-264, [3402]=-264, +[3421]=16, [3441]=51, [3442]=51, [3443]=51, [3444]=440, [3445]=357, [3446]=1417, [3447]=1417, +[3448]=1537, [3449]=1537, [3450]=1537, [3451]=1537, [3452]=51, [3453]=51, [3454]=51, [3461]=1537, +[3462]=51, [3463]=51, [3481]=51, [3483]=1537, [3501]=4, [3502]=4, [3503]=16, [3504]=1637, +[3505]=1637, [3506]=1637, [3507]=1637, [3508]=16, [3509]=16, [3510]=16, [3511]=16, [3512]=8, +[3513]=17, [3514]=17, [3517]=16, [3518]=16, [3519]=188, [3520]=440, [3521]=188, [3522]=188, +[3523]=722, [3524]=148, [3525]=722, [3526]=-201, [3527]=978, [3528]=1417, [3541]=16, [3542]=16, +[3561]=16, [3562]=16, [3563]=16, [3564]=16, [3565]=16, [3566]=51, [3567]=357, [3568]=1497, +[3569]=1497, [3570]=1497, [3578]=222, [3579]=222, [3580]=222, [3581]=222, [3601]=16, [3602]=16, +[3621]=16, [3625]=33, [3626]=33, [3627]=4, [3628]=4, [3629]=-201, [3630]=-201, [3631]=-61, +[3632]=-201, [3633]=-201, [3634]=-201, [3635]=-201, [3636]=722, [3637]=-201, [3638]=-201, [3639]=-201, +[3640]=-201, [3641]=-201, [3642]=-201, [3643]=-201, [3644]=-201, [3645]=-201, [3646]=-201, [3647]=-201, +[3661]=702, [3681]=-141, [3701]=1537, [3702]=1537, [3721]=35, [3741]=44, [3761]=1638, [3762]=1638, +[3763]=1657, [3764]=1657, [3765]=1519, [3781]=1657, [3782]=1638, [3783]=618, [3784]=1497, [3785]=1657, +[3786]=1638, [3787]=1519, [3788]=1116, [3789]=1519, [3790]=1537, [3791]=1116, [3792]=1116, [3801]=1584, +[3802]=1584, [3803]=1657, [3804]=1638, [3821]=3, [3822]=46, [3823]=46, [3824]=46, [3825]=46, +[3841]=357, [3842]=357, [3843]=357, [3844]=490, [3845]=490, [3861]=-284, [3881]=490, [3882]=490, +[3883]=490, [3884]=490, [3901]=154, [3902]=154, [3903]=9, [3904]=9, [3905]=9, [3906]=25, +[3907]=1584, [3908]=490, [3909]=490, [3912]=490, [3913]=490, [3914]=490, [3921]=17, [3922]=17, +[3923]=17, [3924]=17, [3941]=490, [3942]=490, [3961]=490, [3962]=490, [3981]=1584, [3982]=1584, +[4001]=1584, [4002]=1584, [4003]=1584, [4004]=1584, [4005]=490, [4021]=17, [4022]=46, [4023]=46, +[4024]=1584, [4041]=357, [4061]=46, [4062]=3, [4063]=1584, [4081]=1584, [4082]=1584, [4083]=1584, +[4084]=490, [4101]=361, [4102]=361, [4103]=361, [4104]=361, [4105]=361, [4106]=361, [4107]=361, +[4108]=361, [4109]=361, [4110]=361, [4111]=361, [4112]=361, [4113]=361, [4114]=361, [4115]=361, +[4116]=361, [4117]=361, [4118]=361, [4119]=361, [4120]=357, [4121]=1584, [4122]=1584, [4123]=1584, +[4124]=357, [4125]=357, [4126]=1584, [4127]=357, [4128]=1584, [4129]=357, [4130]=357, [4131]=357, +[4132]=1584, [4133]=1584, [4134]=1584, [4135]=357, [4136]=1584, [4141]=490, [4142]=490, [4143]=1477, +[4144]=490, [4145]=490, [4146]=490, [4147]=490, [4148]=490, [4161]=-304, [4181]=-201, [4182]=46, +[4183]=46, [4184]=46, [4185]=46, [4186]=46, [4201]=1584, [4221]=361, [4222]=361, [4223]=46, +[4224]=46, [4241]=1584, [4242]=1584, [4243]=490, [4244]=490, [4245]=490, [4261]=361, [4262]=25, +[4263]=1584, [4264]=1584, [4265]=357, [4266]=357, [4267]=357, [4281]=357, [4282]=1584, [4283]=46, +[4284]=490, [4285]=490, [4286]=1584, [4287]=490, [4288]=490, [4289]=490, [4290]=490, [4291]=490, +[4292]=490, [4293]=1497, [4294]=1497, [4295]=1584, [4296]=46, [4297]=357, [4298]=357, [4300]=1637, +[4301]=490, [4321]=490, [4322]=1584, [4324]=1584, [4341]=1584, [4342]=1584, [4343]=361, [4361]=1584, +[4362]=1584, [4363]=1584, [4381]=490, [4382]=490, [4383]=490, [4384]=490, [4385]=490, [4386]=490, +[4401]=361, [4402]=363, [4403]=361, [4421]=361, [4441]=361, [4442]=361, [4443]=361, [4444]=361, +[4445]=361, [4446]=361, [4447]=361, [4448]=361, [4449]=51, [4450]=51, [4451]=51, [4461]=361, +[4462]=361, [4463]=46, [4464]=361, [4465]=361, [4466]=361, [4467]=361, [4481]=46, [4482]=46, +[4483]=46, [4484]=46, [4485]=-141, [4486]=-141, [4487]=-61, [4488]=-61, [4489]=-61, [4490]=-61, +[4491]=490, [4492]=490, [4493]=1657, [4494]=1637, [4495]=188, [4496]=440, [4501]=490, [4502]=490, +[4503]=490, [4504]=440, [4505]=361, [4506]=361, [4507]=440, [4508]=440, [4509]=440, [4510]=1657, +[4511]=1637, [4512]=1537, [4513]=1537, [4521]=361, [4542]=400, [4561]=1497, [4581]=331, [4601]=133, +[4602]=133, [4603]=133, [4604]=133, [4605]=133, [4606]=133, [4621]=33, [4641]=363, [4642]=1497, +[4661]=1497, [4681]=148, [4701]=1583, [4721]=361, [4722]=148, [4723]=148, [4724]=1583, [4725]=148, +[4726]=46, [4727]=148, [4728]=148, [4729]=1583, [4730]=148, [4731]=148, [4732]=148, [4733]=148, +[4734]=1583, [4735]=1583, [4736]=-61, [4737]=-61, [4738]=-61, [4739]=-61, [4740]=148, [4741]=361, +[4742]=1583, [4743]=1583, [4761]=148, [4762]=148, [4763]=148, [4764]=1583, [4765]=1583, [4766]=1583, +[4767]=400, [4768]=1583, [4769]=1583, [4770]=400, [4771]=2057, [4781]=-61, [4782]=-61, [4783]=-61, +[4784]=-61, [4785]=-61, [4786]=-61, [4787]=47, [4788]=1583, [4801]=618, [4802]=618, [4803]=618, +[4804]=618, [4805]=618, [4806]=618, [4807]=618, [4808]=46, [4809]=618, [4810]=618, [4811]=148, +[4812]=148, [4813]=148, [4821]=400, [4822]=-284, [4841]=400, [4842]=618, [4861]=618, [4862]=1583, +[4863]=618, [4864]=618, [4865]=400, [4866]=1583, [4867]=1583, [4881]=400, [4882]=618, [4883]=618, +[4901]=618, [4902]=618, [4903]=1583, [4904]=400, [4906]=361, [4907]=1583, [4921]=17, [4941]=1637, +[4961]=-61, [4962]=-61, [4963]=-61, [4964]=-61, [4965]=-61, [4966]=400, [4967]=-61, [4968]=-61, +[4969]=-61, [4970]=618, [4971]=28, [4972]=28, [4973]=28, [4974]=1583, [4975]=-61, [4976]=-61, +[4981]=1583, [4982]=1583, [4983]=1583, [4984]=28, [4985]=28, [4986]=28, [4987]=28, [5001]=1583, +[5002]=1583, [5021]=28, [5022]=28, [5023]=28, [5041]=17, [5042]=17, [5043]=17, [5044]=17, +[5045]=17, [5046]=17, [5047]=1583, [5048]=1519, [5049]=1497, [5050]=28, [5051]=28, [5052]=17, +[5054]=618, [5055]=618, [5056]=618, [5057]=618, [5058]=28, [5059]=28, [5060]=28, [5061]=-263, +[5062]=400, [5063]=618, [5064]=400, [5065]=139, [5066]=1519, [5067]=618, [5068]=618, [5081]=1583, +[5082]=618, [5083]=618, [5084]=618, [5085]=618, [5086]=618, [5087]=618, [5088]=400, [5089]=1583, +[5090]=1537, [5091]=1657, [5092]=28, [5093]=28, [5094]=28, [5095]=28, [5096]=28, [5097]=28, +[5098]=28, [5102]=1583, [5103]=-121, [5121]=618, [5122]=2017, [5123]=618, [5124]=618, [5125]=2017, +[5126]=618, [5127]=1583, [5128]=618, [5141]=-182, [5142]=139, [5143]=-182, [5144]=-182, [5145]=-182, +[5146]=-182, [5147]=400, [5148]=-182, [5149]=139, [5150]=490, [5151]=400, [5152]=139, [5153]=139, +[5154]=28, [5155]=361, [5156]=361, [5157]=361, [5158]=361, [5159]=361, [5160]=1583, [5161]=618, +[5162]=28, [5163]=618, [5164]=28, [5165]=361, [5166]=28, [5167]=28, [5168]=139, [5181]=139, +[5201]=618, [5202]=361, [5203]=361, [5204]=361, [5206]=139, [5210]=139, [5211]=139, [5212]=2017, +[5213]=2017, [5214]=2017, [5215]=28, [5216]=28, [5217]=28, [5218]=28, [5219]=28, [5220]=28, +[5221]=28, [5222]=28, [5223]=28, [5224]=28, [5225]=28, [5226]=28, [5227]=28, [5228]=28, +[5229]=28, [5230]=28, [5231]=28, [5232]=28, [5233]=28, [5234]=28, [5235]=28, [5236]=28, +[5237]=28, [5238]=28, [5241]=139, [5242]=361, [5243]=2017, [5244]=618, [5245]=618, [5246]=139, +[5247]=139, [5248]=139, [5249]=361, [5250]=702, [5251]=2017, [5252]=618, [5253]=618, [5261]=9, +[5262]=2017, [5263]=2017, [5264]=139, [5265]=139, [5281]=139, [5282]=2017, [5283]=-121, [5284]=-121, +[5301]=-121, [5302]=-121, [5305]=-121, [5306]=-121, [5307]=-121, [5321]=148, [5341]=2057, [5342]=28, +[5343]=2057, [5344]=28, [5361]=400, [5381]=405, [5382]=2057, [5384]=2057, [5385]=361, [5386]=405, +[5401]=28, [5402]=28, [5403]=28, [5404]=28, [5405]=28, [5406]=28, [5407]=28, [5408]=28, +[5421]=405, [5441]=363, [5461]=28, [5462]=28, [5463]=2017, [5464]=139, [5465]=28, [5466]=2057, +[5481]=85, [5482]=85, [5501]=405, [5502]=-284, [5503]=139, [5504]=28, [5505]=28, [5507]=28, +[5508]=139, [5509]=139, [5510]=139, [5511]=28, [5513]=139, [5514]=28, [5515]=2057, [5517]=139, +[5518]=2557, [5519]=2557, [5521]=28, [5522]=46, [5524]=28, [5525]=2557, [5526]=493, [5527]=493, +[5528]=2557, [5529]=2057, [5531]=2057, [5533]=28, [5534]=16, [5535]=16, [5536]=16, [5537]=28, +[5538]=28, [5541]=1, [5542]=139, [5543]=139, [5544]=139, [5545]=12, [5561]=405, [5581]=405, +[5582]=2057, [5601]=139, [5621]=-262, [5622]=-262, [5623]=-262, [5624]=-262, [5625]=-262, [5626]=-262, +[5627]=-262, [5628]=-262, [5629]=-262, [5630]=-262, [5631]=-262, [5632]=-262, [5633]=-262, [5634]=-262, +[5635]=-262, [5636]=-262, [5637]=-262, [5638]=-262, [5639]=-262, [5640]=-262, [5641]=-262, [5642]=-262, +[5643]=-262, [5644]=-262, [5645]=-262, [5646]=-262, [5647]=-262, [5648]=-262, [5649]=-262, [5650]=-262, +[5651]=-262, [5652]=-262, [5654]=-262, [5655]=-262, [5656]=-262, [5657]=-262, [5658]=-262, [5659]=-262, +[5660]=-262, [5661]=-262, [5662]=-262, [5663]=-262, [5672]=-262, [5673]=-262, [5674]=-262, [5675]=-262, +[5676]=-262, [5677]=-262, [5678]=-262, [5679]=-262, [5680]=-262, [5713]=148, [5721]=139, [5722]=2437, +[5723]=2437, [5724]=2437, [5725]=2437, [5726]=1637, [5727]=1637, [5728]=2437, [5729]=1637, [5730]=1637, +[5741]=405, [5742]=139, [5761]=2437, [5762]=400, [5763]=405, [5781]=139, [5801]=440, [5802]=440, +[5803]=28, [5804]=28, [5821]=405, [5845]=139, [5846]=139, [5848]=2017, [5861]=28, [5862]=28, +[5863]=440, [5881]=406, [5882]=361, [5883]=361, [5884]=361, [5885]=361, [5886]=361, [5887]=361, +[5888]=361, [5889]=361, [5890]=361, [5891]=361, [5892]=2597, [5893]=2597, [5901]=85, [5902]=85, +[5903]=28, [5904]=28, [5921]=-263, [5922]=-263, [5923]=-263, [5924]=-263, [5925]=-263, [5926]=-263, +[5927]=-263, [5928]=-263, [5929]=-263, [5930]=-263, [5931]=-263, [5932]=-263, [5941]=139, [5942]=139, +[5943]=405, [5944]=28, [5961]=1497, [5981]=618, [6000]=-61, [6001]=-263, [6002]=-263, [6004]=28, +[6021]=139, [6022]=139, [6023]=28, [6024]=139, [6025]=28, [6026]=139, [6027]=405, [6028]=618, +[6029]=618, [6030]=618, [6031]=1216, [6032]=1216, [6041]=139, [6042]=139, [6061]=-261, [6062]=-261, +[6063]=-261, [6064]=-261, [6065]=-261, [6066]=-261, [6067]=-261, [6068]=-261, [6069]=-261, [6070]=-261, +[6071]=-261, [6072]=-261, [6073]=-261, [6074]=-261, [6075]=-261, [6076]=-261, [6081]=-261, [6082]=-261, +[6083]=-261, [6084]=-261, [6085]=-261, [6086]=-261, [6087]=-261, [6088]=-261, [6089]=-261, [6101]=-261, +[6102]=-261, [6103]=-261, [6121]=-263, [6122]=-263, [6123]=-263, [6124]=-263, [6125]=-263, [6126]=-263, +[6127]=-263, [6128]=-263, [6129]=-263, [6130]=-263, [6131]=361, [6132]=405, [6133]=139, [6134]=405, +[6135]=139, [6136]=139, [6141]=405, [6142]=405, [6143]=405, [6144]=139, [6145]=139, [6146]=139, +[6147]=139, [6148]=139, [6161]=405, [6162]=361, [6163]=2017, [6164]=139, [6165]=28, [6181]=40, +[6182]=1519, [6183]=1519, [6184]=28, [6185]=139, [6186]=1519, [6187]=139, [6221]=1769, [6241]=1769, +[6261]=40, [6281]=40, [6282]=406, [6283]=406, [6284]=406, [6285]=40, [6301]=406, [6321]=130, +[6322]=130, [6323]=130, [6324]=130, [6341]=148, [6342]=148, [6343]=148, [6344]=1657, [6361]=17, +[6362]=17, [6363]=17, [6364]=17, [6365]=17, [6381]=406, [6382]=17, [6383]=331, [6384]=17, +[6385]=17, [6386]=17, [6387]=38, [6388]=38, [6389]=28, [6390]=28, [6391]=38, [6392]=38, +[6393]=406, [6394]=363, [6395]=154, [6401]=406, [6402]=1519, [6403]=1519, [6421]=406, [6441]=331, +[6442]=331, [6461]=406, [6462]=331, [6481]=406, [6482]=331, [6501]=1519, [6502]=1583, [6503]=331, +[6504]=331, [6521]=722, [6522]=722, [6523]=406, [6541]=17, [6542]=406, [6543]=17, [6544]=331, +[6545]=331, [6546]=331, [6547]=331, [6548]=406, [6561]=719, [6562]=719, [6563]=719, [6564]=719, +[6565]=719, [6566]=1637, [6567]=1637, [6568]=405, [6569]=1583, [6570]=15, [6571]=331, [6581]=331, +[6582]=15, [6583]=15, [6584]=15, [6585]=15, [6601]=15, [6602]=1583, [6603]=618, [6604]=618, +[6605]=618, [6606]=618, [6607]=-101, [6608]=-101, [6609]=-101, [6610]=-304, [6611]=-304, [6612]=-304, +[6621]=331, [6622]=-324, [6623]=-324, [6624]=-324, [6625]=-324, [6626]=722, [6627]=400, [6628]=400, +[6629]=406, [6641]=331, [6642]=2717, [6643]=2717, [6644]=2717, [6645]=2717, [6646]=2717, [6661]=2257, +[6662]=2257, [6681]=-162, [6701]=-162, [6721]=1537, [6722]=131, [6741]=2597, [6761]=1657, [6762]=1657, +[6781]=2597, [6801]=2597, [6804]=16, [6805]=16, [6821]=16, [6822]=16, [6823]=16, [6824]=16, +[6825]=2597, [6826]=2597, [6827]=2597, [6881]=2597, [6901]=2597, [6921]=719, [6922]=331, [6941]=2597, +[6942]=2597, [6943]=2597, [6961]=-22, [6962]=-22, [6963]=-22, [6964]=-22, [6981]=718, [6982]=2597, +[6983]=-22, [6984]=-22, [6985]=2597, [7001]=2597, [7002]=2597, [7003]=357, [7021]=-22, [7022]=-22, +[7023]=-22, [7024]=-22, [7025]=-22, [7026]=2597, [7027]=2597, [7028]=2100, [7029]=2100, [7041]=2100, +[7042]=-22, [7043]=-22, [7044]=2100, [7045]=-22, [7046]=2100, [7061]=-22, [7062]=-22, [7063]=-22, +[7064]=2100, [7065]=2100, [7066]=2100, [7067]=2100, [7068]=2100, [7070]=2100, [7081]=2597, [7082]=2597, +[7101]=2597, [7102]=2597, [7121]=2597, [7122]=2597, [7123]=2597, [7124]=2597, [7141]=2597, [7142]=2597, +[7161]=2597, [7162]=2597, [7163]=2597, [7164]=2597, [7165]=2597, [7166]=2597, [7167]=2597, [7168]=2597, +[7169]=2597, [7170]=2597, [7171]=2597, [7172]=2597, [7181]=2597, [7201]=1584, [7202]=2597, [7223]=2597, +[7224]=2597, [7241]=2597, [7261]=2597, [7281]=2597, [7282]=2597, [7301]=2597, [7302]=2597, [7321]=-304, +[7341]=1637, [7342]=1537, [7361]=2597, [7362]=2597, [7363]=2597, [7364]=2597, [7365]=2597, [7366]=2597, +[7367]=2597, [7368]=2597, [7381]=2597, [7382]=2597, [7383]=141, [7385]=2597, [7386]=2597, [7401]=2597, +[7402]=2597, [7421]=2597, [7422]=2597, [7423]=2597, [7424]=2597, [7425]=2597, [7426]=2597, [7427]=2597, +[7428]=2597, [7429]=2557, [7441]=2557, [7461]=2557, [7462]=2557, [7463]=2557, [7478]=2557, [7479]=2557, +[7480]=2557, [7481]=2557, [7482]=2557, [7483]=2557, [7484]=2557, [7485]=2557, [7486]=16, [7487]=2717, +[7488]=2557, [7489]=2557, [7490]=1637, [7491]=1637, [7492]=2557, [7493]=1637, [7494]=2557, [7495]=1519, +[7496]=1519, [7497]=1519, [7498]=2557, [7499]=2557, [7500]=2557, [7501]=2557, [7502]=2557, [7503]=2557, +[7504]=2557, [7505]=2557, [7506]=2557, [7507]=2557, [7508]=2557, [7509]=2159, [7521]=-344, [7541]=15, +[7562]=-61, [7563]=-61, [7564]=-61, [7581]=-61, [7582]=-61, [7583]=-61, [7601]=-61, [7602]=-61, +[7603]=-61, [7604]=1584, [7621]=-262, [7622]=-262, [7623]=-61, [7624]=-61, [7625]=-61, [7626]=-61, +[7627]=-61, [7628]=-61, [7629]=-61, [7630]=-61, [7631]=-61, [7632]=-261, [7633]=-261, [7634]=-261, +[7635]=-261, [7636]=-261, [7637]=-141, [7638]=-141, [7639]=-141, [7640]=-141, [7641]=-141, [7642]=-141, +[7643]=-141, [7644]=-141, [7645]=-141, [7646]=-141, [7647]=-141, [7648]=-141, [7649]=-121, [7650]=-121, +[7651]=-121, [7652]=-121, [7653]=-121, [7654]=-121, [7655]=-121, [7656]=-121, [7657]=-121, [7658]=-121, +[7659]=-121, [7666]=-141, [7667]=-82, [7668]=-82, [7701]=51, [7703]=2557, [7721]=357, [7722]=51, +[7723]=51, [7724]=51, [7725]=357, [7726]=357, [7727]=51, [7728]=51, [7729]=51, [7730]=357, +[7731]=357, [7732]=357, [7733]=357, [7734]=357, [7735]=357, [7736]=51, [7737]=51, [7738]=357, +[7761]=1583, [7781]=1519, [7782]=1519, [7783]=1637, [7784]=1637, [7785]=-344, [7786]=-344, [7787]=-344, +[7788]=3277, [7789]=3277, [7791]=1519, [7792]=1657, [7793]=1519, [7794]=1519, [7795]=1519, [7796]=1519, +[7798]=1657, [7799]=1657, [7800]=1657, [7801]=1657, [7802]=1537, [7803]=1537, [7804]=1537, [7805]=1537, +[7806]=1537, [7807]=1537, [7808]=1537, [7809]=1537, [7810]=33, [7811]=1537, [7812]=1537, [7813]=1497, +[7814]=1497, [7815]=47, [7816]=47, [7817]=1497, [7818]=1497, [7819]=1497, [7820]=1638, [7821]=1638, +[7822]=1638, [7823]=1638, [7824]=1637, [7825]=1638, [7826]=1637, [7827]=1637, [7828]=47, [7829]=47, +[7830]=47, [7831]=1637, [7832]=1637, [7833]=1637, [7834]=1637, [7835]=1637, [7836]=1637, [7837]=1637, +[7839]=47, [7840]=47, [7841]=47, [7842]=47, [7843]=47, [7844]=47, [7845]=47, [7846]=47, +[7847]=47, [7848]=2717, [7849]=47, [7850]=47, [7861]=47, [7862]=47, [7863]=3277, [7864]=3277, +[7865]=3277, [7866]=3277, [7867]=3277, [7868]=3277, [7871]=3277, [7872]=3277, [7873]=3277, [7874]=3277, +[7875]=3277, [7876]=3277, [7877]=2557, [7881]=-364, [7882]=-364, [7883]=-364, [7884]=-364, [7885]=-364, +[7886]=3277, [7887]=3277, [7888]=3277, [7889]=-364, [7890]=-364, [7891]=-364, [7892]=-364, [7893]=-364, +[7894]=-364, [7895]=-364, [7896]=-364, [7897]=-364, [7898]=-364, [7899]=-364, [7900]=-364, [7901]=-364, +[7902]=-364, [7903]=-364, [7905]=1537, [7907]=-364, [7921]=3277, [7922]=3277, [7923]=3277, [7924]=3277, +[7925]=3277, [7926]=1637, [7927]=-364, [7928]=-364, [7929]=-364, [7930]=-364, [7931]=-364, [7932]=-364, +[7933]=-364, [7934]=-364, [7935]=-364, [7936]=-364, [7937]=-364, [7938]=-364, [7939]=-364, [7940]=-364, +[7941]=-364, [7942]=-364, [7943]=-364, [7944]=-364, [7945]=-364, [7946]=-364, [7981]=-364, [8001]=3277, +[8041]=1977, [8042]=1977, [8043]=1977, [8044]=1977, [8045]=1977, [8046]=1977, [8047]=1977, [8048]=1977, +[8049]=1977, [8050]=1977, [8051]=1977, [8052]=1977, [8053]=19, [8054]=19, [8055]=19, [8056]=19, +[8057]=19, [8058]=19, [8059]=19, [8060]=19, [8061]=19, [8062]=19, [8063]=19, [8064]=19, +[8065]=19, [8066]=19, [8067]=19, [8068]=19, [8069]=19, [8070]=19, [8071]=19, [8072]=19, +[8073]=19, [8074]=19, [8075]=19, [8076]=19, [8077]=19, [8078]=19, [8079]=19, [8080]=3358, +[8081]=3358, [8101]=1977, [8102]=1977, [8103]=1977, [8104]=1977, [8105]=3358, [8106]=1977, [8107]=1977, +[8108]=1977, [8109]=1977, [8110]=1977, [8111]=1977, [8112]=1977, [8113]=1977, [8114]=3358, [8115]=3358, +[8116]=1977, [8117]=1977, [8118]=1977, [8119]=1977, [8120]=3358, [8121]=3358, [8122]=3358, [8123]=3358, +[8124]=3358, [8141]=1977, [8142]=1977, [8143]=1977, [8144]=1977, [8145]=1977, [8146]=1977, [8147]=1977, +[8148]=1977, [8149]=-22, [8150]=-22, [8151]=-261, [8153]=-261, [8154]=3358, [8155]=3358, [8156]=3358, +[8157]=3358, [8158]=3358, [8159]=3358, [8160]=3358, [8161]=3358, [8162]=3358, [8163]=3358, [8164]=3358, +[8165]=3358, [8166]=3358, [8167]=3358, [8168]=3358, [8169]=3358, [8170]=3358, [8171]=3358, [8181]=19, +[8182]=440, [8183]=33, [8184]=1977, [8185]=1977, [8186]=1977, [8187]=1977, [8188]=1977, [8189]=1977, +[8190]=1977, [8191]=1977, [8192]=1977, [8193]=-101, [8194]=-101, [8195]=19, [8196]=19, [8201]=19, +[8221]=-101, [8222]=-364, [8223]=-364, [8224]=-101, [8225]=-101, [8227]=19, [8228]=-101, [8229]=-101, +[8231]=-261, [8232]=-261, [8233]=-162, [8234]=-162, [8235]=-162, [8236]=-162, [8238]=19, [8239]=19, +[8240]=19, [8241]=51, [8242]=51, [8243]=19, [8246]=19, [8249]=-162, [8250]=-161, [8251]=-161, +[8252]=-161, [8253]=-161, [8254]=-262, [8255]=-262, [8256]=-262, [8257]=-262, [8258]=-82, [8260]=3358, +[8261]=3358, [8262]=3358, [8263]=3358, [8264]=3358, [8265]=3358, [8266]=3277, [8267]=3277, [8268]=3277, +[8269]=3277, [8271]=2597, [8272]=2597, [8273]=47, [8275]=1537, [8276]=1637, [8277]=1377, [8278]=1377, +[8279]=1377, [8280]=1377, [8281]=1377, [8282]=1377, [8283]=1377, [8284]=1377, [8285]=1377, [8286]=1377, +[8287]=1377, [8288]=2677, [8289]=3277, [8290]=3277, [8291]=3277, [8292]=3277, [8293]=3277, [8294]=3277, +[8295]=3277, [8296]=3277, [8297]=3358, [8298]=3358, [8299]=3358, [8300]=3358, [8301]=1377, [8302]=1377, +[8303]=1377, [8304]=1377, [8305]=1377, [8306]=1377, [8307]=-304, [8308]=1377, [8309]=1377, [8310]=1377, +[8311]=-22, [8312]=-22, [8313]=-304, [8314]=1377, [8315]=1377, [8316]=1377, [8317]=-304, [8318]=1377, +[8319]=1377, [8320]=1377, [8321]=1377, [8322]=-22, [8323]=1377, [8324]=1377, [8331]=1377, [8332]=1377, +[8333]=1377, [8341]=1377, [8342]=1377, [8343]=1377, [8348]=1377, [8349]=1377, [8351]=1377, [8352]=1377, +[8353]=-22, [8354]=-22, [8355]=-22, [8356]=-22, [8357]=-22, [8358]=-22, [8359]=-22, [8360]=-22, +[8361]=1377, [8362]=1377, [8363]=1377, [8364]=1377, [8365]=440, [8366]=440, [8367]=-25, [8368]=3277, +[8369]=2597, [8370]=3358, [8371]=-25, [8372]=3277, [8373]=-22, [8374]=3358, [8375]=2839, [8376]=1377, +[8377]=1377, [8378]=1377, [8379]=1377, [8380]=1377, [8381]=1377, [8382]=1377, [8383]=2839, [8384]=3358, +[8385]=-25, [8386]=3277, [8387]=2839, [8388]=1637, [8389]=3277, [8390]=3358, [8391]=3358, [8392]=3358, +[8393]=3358, [8394]=3358, [8395]=3358, [8396]=3358, [8397]=3358, [8398]=3358, [8399]=3277, [8400]=3277, +[8401]=3277, [8402]=3277, [8403]=3277, [8404]=3277, [8405]=3277, [8406]=3277, [8407]=3277, [8408]=3277, +[8409]=-22, [8410]=-82, [8411]=-82, [8412]=-82, [8413]=-82, [8414]=-141, [8415]=-141, [8416]=-141, +[8417]=-81, [8418]=-141, [8419]=-61, [8420]=-61, [8421]=-61, [8422]=-61, [8423]=-81, [8424]=-81, +[8425]=-81, [8426]=3277, [8427]=3277, [8428]=3277, [8429]=3277, [8430]=3277, [8431]=3277, [8432]=3277, +[8433]=3277, [8434]=3277, [8435]=3277, [8436]=3358, [8437]=3358, [8438]=3358, [8439]=3358, [8440]=3358, +[8441]=3358, [8442]=3358, [8443]=3358, [8446]=-1, [8447]=493, [8460]=361, [8461]=1769, [8462]=361, +[8464]=1769, [8465]=361, [8466]=361, [8467]=361, [8469]=361, [8470]=361, [8471]=618, [8481]=1769, +[8484]=1769, [8485]=1769, [8492]=-365, [8493]=-365, [8494]=-365, [8495]=-365, [8496]=1377, [8497]=1377, +[8498]=1377, [8499]=-365, [8500]=-365, [8501]=1377, [8502]=1377, [8503]=-365, [8504]=-365, [8505]=-365, +[8506]=-365, [8507]=1377, [8508]=1377, [8509]=-365, [8510]=-365, [8511]=-365, [8512]=-365, [8513]=-365, +[8514]=-365, [8515]=-365, [8516]=-365, [8517]=-365, [8518]=-365, [8519]=1377, [8520]=-365, [8521]=-365, +[8522]=-365, [8523]=-365, [8524]=-365, [8525]=-365, [8526]=-365, [8527]=-365, [8528]=-365, [8529]=-365, +[8532]=-365, [8533]=-365, [8534]=1377, [8535]=1377, [8536]=1377, [8537]=1377, [8538]=1377, [8539]=1377, +[8540]=1377, [8541]=1377, [8542]=-365, [8543]=-365, [8544]=3428, [8545]=-365, [8546]=-365, [8548]=1377, +[8549]=-365, [8550]=-365, [8551]=33, [8552]=33, [8553]=33, [8554]=33, [8555]=440, [8556]=3428, +[8557]=3428, [8558]=3428, [8559]=3428, [8560]=3428, [8561]=3428, [8562]=3428, [8572]=1377, [8573]=1377, +[8574]=1377, [8575]=16, [8576]=440, [8577]=440, [8578]=2717, [8579]=3428, [8580]=-365, [8581]=-365, +[8582]=-365, [8583]=-365, [8584]=440, [8585]=440, [8586]=440, [8587]=440, [8588]=-365, [8589]=-365, +[8590]=-365, [8591]=-365, [8592]=3428, [8593]=3428, [8594]=3428, [8595]=3428, [8596]=3428, [8597]=440, +[8598]=440, [8599]=440, [8600]=-365, [8601]=-365, [8602]=3428, [8603]=3428, [8604]=-365, [8605]=-365, +[8606]=440, [8607]=-365, [8608]=-365, [8609]=-365, [8610]=-365, [8611]=-365, [8612]=-365, [8613]=-365, +[8614]=-365, [8615]=-365, [8616]=-365, [8619]=-366, [8620]=440, [8621]=3428, [8622]=3428, [8623]=3428, +[8624]=3428, [8625]=3428, [8626]=3428, [8627]=3428, [8628]=3428, [8629]=3428, [8630]=3428, [8631]=3428, +[8632]=3428, [8633]=3428, [8634]=3428, [8635]=-366, [8636]=-366, [8637]=3428, [8638]=3428, [8639]=3428, +[8640]=3428, [8641]=3428, [8642]=-366, [8643]=-366, [8644]=-366, [8645]=-366, [8646]=-366, [8647]=-366, +[8648]=-366, [8649]=-366, [8650]=-366, [8651]=-366, [8652]=-366, [8653]=-366, [8654]=-366, [8655]=3428, +[8656]=3428, [8657]=3428, [8658]=3428, [8659]=3428, [8660]=3428, [8661]=3428, [8662]=3428, [8663]=3428, +[8664]=3428, [8665]=3428, [8666]=3428, [8667]=3428, [8668]=3428, [8669]=3428, [8670]=-366, [8671]=-366, +[8672]=-366, [8673]=-366, [8674]=-366, [8675]=-366, [8676]=-366, [8677]=-366, [8678]=-366, [8679]=-366, +[8680]=-366, [8681]=-366, [8682]=-366, [8683]=-366, [8684]=-366, [8685]=-366, [8686]=-366, [8687]=1377, +[8688]=-366, [8689]=3428, [8690]=3428, [8691]=3428, [8692]=3428, [8693]=3428, [8694]=3428, [8695]=3428, +[8696]=3428, [8697]=3428, [8698]=3428, [8699]=3428, [8700]=3428, [8701]=3428, [8702]=3428, [8703]=3428, +[8704]=3428, [8705]=3428, [8706]=3428, [8707]=3428, [8708]=3428, [8709]=3428, [8710]=3428, [8711]=3428, +[8712]=3428, [8713]=-366, [8714]=-366, [8715]=-366, [8716]=-366, [8717]=-366, [8718]=-366, [8719]=-366, +[8720]=-366, [8721]=-366, [8722]=-366, [8723]=-366, [8724]=-366, [8725]=-366, [8726]=-366, [8727]=-366, +[8728]=440, [8729]=16, [8730]=2677, [8731]=1377, [8732]=1377, [8733]=1417, [8734]=141, [8735]=493, +[8736]=493, [8737]=1377, [8738]=1377, [8739]=1377, [8740]=1377, [8741]=493, [8742]=440, [8743]=1377, +[8744]=-22, [8745]=1377, [8746]=-22, [8747]=440, [8748]=440, [8749]=440, [8750]=440, [8751]=440, +[8752]=440, [8753]=440, [8754]=440, [8755]=440, [8756]=440, [8757]=440, [8758]=440, [8759]=440, +[8760]=440, [8761]=440, [8762]=-22, [8763]=-22, [8764]=440, [8765]=440, [8766]=440, [8767]=-22, +[8768]=-22, [8769]=-22, [8770]=1377, [8771]=1377, [8772]=1377, [8773]=1377, [8774]=1377, [8775]=1377, +[8776]=1377, [8777]=1377, [8778]=1377, [8779]=1377, [8780]=1377, [8781]=1377, [8782]=1377, [8783]=1377, +[8784]=3428, [8785]=1377, [8786]=1377, [8787]=1377, [8788]=-22, [8789]=3428, [8790]=3428, [8791]=3429, +[8792]=-365, [8793]=-365, [8794]=-365, [8795]=-365, [8796]=-365, [8797]=-365, [8798]=618, [8799]=-22, +[8800]=1377, [8801]=3428, [8802]=3428, [8803]=-22, [8804]=1377, [8805]=1377, [8806]=1377, [8807]=1377, +[8808]=1377, [8809]=1377, [8810]=1377, [8811]=-367, [8812]=-367, [8813]=-367, [8814]=-367, [8815]=-367, +[8816]=-367, [8817]=-367, [8818]=-367, [8819]=-367, [8820]=-367, [8821]=-367, [8822]=-367, [8823]=-367, +[8824]=-367, [8825]=-367, [8826]=-367, [8827]=-22, [8828]=-22, [8829]=1377, [8830]=-367, [8831]=-367, +[8832]=-367, [8833]=-367, [8834]=-367, [8835]=-367, [8836]=-367, [8837]=-367, [8838]=-367, [8839]=-367, +[8840]=-367, [8841]=-367, [8842]=-367, [8843]=-367, [8844]=-367, [8845]=-367, [8846]=-365, [8847]=-365, +[8848]=-365, [8849]=-365, [8850]=-365, [8851]=-365, [8852]=-365, [8853]=-365, [8854]=-365, [8855]=-365, +[8857]=1377, [8858]=1377, [8859]=1377, [8860]=-22, [8861]=-22, [8862]=-366, [8863]=-366, [8864]=-366, +[8865]=-366, [8866]=-366, [8867]=-366, [8868]=-241, [8870]=-366, [8871]=-366, [8872]=-366, [8873]=-366, +[8874]=-366, [8875]=-366, [8876]=-366, [8877]=-366, [8878]=-366, [8879]=-366, [8880]=-366, [8881]=-366, +[8882]=-366, [8883]=-366, [8893]=440, [8897]=-22, [8898]=-22, [8899]=-22, [8900]=-22, [8901]=-22, +[8902]=-22, [8903]=-22, [8904]=-22, [8905]=1537, [8906]=1537, [8907]=1537, [8908]=1537, [8909]=1537, +[8910]=1537, [8911]=1537, [8912]=1537, [8913]=1637, [8914]=1637, [8915]=1637, [8916]=1637, [8917]=1637, +[8918]=1637, [8919]=1637, [8920]=1637, [8921]=440, [8922]=440, [8923]=440, [8924]=440, [8925]=440, +[8926]=1537, [8927]=1637, [8928]=440, [8929]=139, [8930]=139, [8931]=1537, [8932]=1537, [8933]=1537, +[8934]=1537, [8935]=1537, [8936]=1537, [8937]=1537, [8938]=1637, [8939]=1637, [8940]=1637, [8941]=1637, +[8942]=1637, [8943]=1637, [8944]=1637, [8945]=2017, [8946]=139, [8947]=139, [8948]=2557, [8949]=2557, +[8950]=2557, [8951]=1537, [8952]=1537, [8953]=1537, [8954]=1537, [8955]=1537, [8956]=1537, [8957]=1637, +[8958]=1537, [8959]=1537, [8960]=25, [8961]=25, [8962]=25, [8963]=25, [8964]=25, [8965]=25, +[8966]=1583, [8967]=2557, [8968]=2017, [8969]=2057, [8970]=2079, [8977]=1537, [8978]=1637, [8979]=-22, +[8980]=-22, [8981]=-22, [8982]=-22, [8983]=-22, [8984]=-22, [8985]=25, [8986]=25, [8987]=25, +[8988]=25, [8989]=1583, [8990]=2557, [8991]=2017, [8992]=2057, [8993]=-22, [8994]=25, [8995]=1583, +[8996]=25, [8997]=1537, [8998]=1637, [8999]=1537, [9000]=1537, [9001]=1537, [9002]=1537, [9003]=1537, +[9004]=1537, [9005]=1537, [9006]=1537, [9007]=1637, [9008]=1637, [9009]=1637, [9010]=1637, [9011]=1637, +[9012]=1637, [9013]=1637, [9014]=1637, [9015]=1584, [9016]=1637, [9017]=1637, [9018]=1637, [9019]=1637, +[9020]=1637, [9021]=1637, [9022]=1637, [9023]=1377, [9024]=-22, [9025]=-22, [9026]=-22, [9027]=-22, +[9028]=-22, [9029]=-22, [9032]=1637, [9033]=3456, [9034]=3456, [9036]=3456, [9037]=3456, [9038]=3456, +[9039]=3456, [9040]=3456, [9041]=3456, [9042]=3456, [9043]=3456, [9044]=3456, [9045]=3456, [9046]=3456, +[9047]=3456, [9048]=3456, [9049]=3456, [9050]=3456, [9051]=-263, [9052]=-263, [9053]=-263, [9054]=3456, +[9055]=3456, [9056]=3456, [9057]=3456, [9058]=3456, [9059]=3456, [9060]=3456, [9061]=3456, [9063]=-263, +[9068]=3456, [9069]=3456, [9070]=3456, [9071]=3456, [9072]=3456, [9073]=3456, [9074]=3456, [9075]=3456, +[9077]=3456, [9078]=3456, [9079]=3456, [9080]=3456, [9081]=3456, [9082]=3456, [9083]=3456, [9084]=3456, +[9085]=-368, [9086]=3456, [9087]=3456, [9088]=3456, [9089]=3456, [9090]=3456, [9091]=3456, [9092]=3456, +[9093]=3456, [9094]=-368, [9095]=3456, [9096]=3456, [9097]=3456, [9098]=3456, [9099]=3456, [9100]=3456, +[9101]=3456, [9102]=3456, [9103]=3456, [9104]=3456, [9105]=3456, [9106]=3456, [9107]=3456, [9108]=3456, +[9109]=3456, [9110]=3456, [9111]=3456, [9112]=3456, [9113]=3456, [9114]=3456, [9115]=3456, [9116]=3456, +[9117]=3456, [9118]=3456, [9120]=3456, [9121]=139, [9122]=139, [9123]=139, [9124]=139, [9125]=139, +[9126]=139, [9127]=139, [9128]=139, [9129]=139, [9131]=139, [9132]=139, [9136]=139, [9137]=139, +[9141]=139, [9142]=139, [9153]=-368, [9154]=-368, [9165]=139, [9178]=139, [9179]=139, [9181]=139, +[9182]=139, [9183]=139, [9184]=139, [9185]=139, [9186]=139, [9187]=139, [9188]=139, [9190]=139, +[9191]=139, [9194]=139, [9195]=139, [9196]=139, [9197]=139, [9198]=139, [9200]=139, [9201]=139, +[9202]=139, [9203]=139, [9204]=139, [9205]=139, [9206]=139, [9208]=1977, [9209]=1977, [9210]=1977, +[9211]=139, [9213]=139, [9221]=139, [9222]=139, [9223]=139, [9224]=139, [9225]=139, [9226]=139, +[9227]=139, [9228]=139, [9229]=3456, [9230]=3456, [9232]=3456, [9233]=3456, [9234]=3456, [9235]=3456, +[9236]=3456, [9237]=3456, [9238]=3456, [9239]=3456, [9240]=3456, [9241]=3456, [9242]=3456, [9243]=3456, +[9244]=3456, [9245]=3456, [9246]=3456, [9247]=-368, [9248]=1377, [9250]=-344, [9251]=-344, [9257]=-344, +[9259]=33, [9260]=-368, [9261]=-368, [9262]=-368, [9263]=-368, [9264]=-368, [9265]=-368, [9266]=618, +[9267]=17, [9268]=440, [9269]=-344, [9270]=-344, [9271]=-344, [9272]=33, [9292]=-368, [9295]=-368, +[9299]=-368, [9300]=-368, [9301]=-368, [9302]=-368, [9304]=-368, [9310]=-368, [9317]=-368, [9318]=-368, +[9319]=-369, [9320]=-368, [9321]=-368, [9322]=-369, [9323]=-369, [9324]=-369, [9325]=-369, [9326]=-369, +[9330]=-369, [9331]=-369, [9332]=-369, [9338]=1377, [9339]=-369, [9341]=-368, [9362]=-161, [9364]=-161, +[9365]=-369, [9367]=-369, [9368]=-369, [9378]=139, [9386]=-369, [9388]=-369, [9389]=-369, [9415]=1377, +[9416]=1377, [9419]=1377, [9422]=1377, [9664]=139, [9665]=139, [41747]=12, [41895]=12, [50003]=-162, +[60050]=12, +} +Codex.WdbRep = { +[1036]={87,3000}, [1367]={93,3000}, [1368]={92,3000}, [1369]={93,3000}, [1370]={92,3000}, [1371]={93,3000}, [1373]={92,3000}, [1374]={92,3000}, +[1375]={93,3000}, [1380]={92,3000}, [1381]={93,3000}, [1382]={92,3000}, [1384]={92,3000}, [1385]={93,3000}, [1386]={93,3000}, [4621]={87,3000}, +[5504]={529,21000}, [5507]={529,21000}, [5513]={529,21000}, [5517]={529,42000}, [5521]={529,42000}, [5524]={529,42000}, [6823]={749,9000}, [8053]={270,3000}, +[8054]={270,9000}, [8055]={270,21000}, [8056]={270,3000}, [8057]={270,3000}, [8058]={270,3000}, [8059]={270,3000}, [8060]={270,3000}, [8061]={270,3000}, +[8062]={270,3000}, [8063]={270,3000}, [8064]={270,9000}, [8065]={270,21000}, [8066]={270,9000}, [8067]={270,21000}, [8068]={270,9000}, [8069]={270,21000}, +[8070]={270,9000}, [8071]={270,21000}, [8072]={270,9000}, [8073]={270,21000}, [8074]={270,9000}, [8075]={270,21000}, [8076]={270,9000}, [8077]={270,21000}, +[8078]={270,9000}, [8079]={270,21000}, [8196]={270,9000}, [8243]={270,21000}, [8246]={270,42000}, [8481]={576,42000}, [8484]={576,42000}, [8485]={576,42000}, +[8548]={609,3000}, [8556]={609,9000}, [8557]={609,21000}, [8558]={609,42000}, [8560]={910,3000}, [8561]={910,3000}, [8562]={910,9000}, [8572]={609,9000}, +[8573]={609,42000}, [8574]={609,21000}, [8592]={910,3000}, [8593]={910,3000}, [8603]={910,9000}, [8622]={910,9000}, [8623]={910,3000}, [8624]={910,3000}, +[8627]={910,9000}, [8628]={910,3000}, [8629]={910,3000}, [8631]={910,3000}, [8632]={910,3000}, [8633]={910,9000}, [8638]={910,9000}, [8639]={910,3000}, +[8640]={910,3000}, [8656]={910,9000}, [8657]={910,3000}, [8658]={910,3000}, [8661]={910,9000}, [8662]={910,3000}, [8663]={910,3000}, [8666]={910,9000}, +[8667]={910,3000}, [8668]={910,3000}, [8689]={609,21000}, [8690]={609,21000}, [8691]={609,21000}, [8692]={609,21000}, [8693]={609,21000}, [8694]={609,21000}, +[8695]={609,21000}, [8696]={609,21000}, [8697]={609,9000}, [8698]={609,9000}, [8699]={609,9000}, [8700]={609,9000}, [8701]={609,9000}, [8702]={609,9000}, +[8703]={609,9000}, [8704]={609,9000}, [8705]={609,42000}, [8706]={609,42000}, [8707]={609,42000}, [8708]={609,42000}, [8709]={609,42000}, [8710]={609,42000}, +[8711]={609,42000}, [8712]={609,42000}, [9121]={529,9000}, [9122]={529,21000}, [9123]={529,42000}, [9236]={529,42000}, [9237]={529,42000}, [9240]={529,42000}, +[9243]={529,42000}, [9246]={529,42000}, [9248]={609,9000}, [9378]={529,42000}, +} +Codex.WdbInfo = { +[17]=81, [19]=1, [55]=1, [99]=1, [105]=1, [115]=1, [166]=81, [167]=81, +[168]=81, [169]=1, [176]=1, [193]=1, [197]=1, [202]=1, [206]=1, [208]=1, +[211]=1, [214]=81, [228]=1, [248]=1, [249]=1, [253]=1, [255]=1, [256]=1, +[271]=1, [278]=1, [303]=1, [304]=1, [314]=1, [377]=81, [378]=81, [386]=81, +[387]=81, [388]=81, [391]=81, [442]=1, [450]=1, [452]=1, [474]=1, [504]=1, +[517]=1, [518]=1, [519]=1, [520]=1, [531]=1, [540]=1, [541]=1, [543]=1, +[547]=1, [591]=1, [611]=1, [613]=1, [614]=1, [618]=1, [628]=1, [629]=1, +[630]=1, [631]=1, [639]=1, [640]=1, [643]=1, [644]=1, [645]=1, [646]=1, +[652]=1, [656]=1, [673]=1, [679]=1, [680]=1, [682]=1, [684]=1, [685]=1, +[694]=1, [695]=1, [696]=1, [697]=1, [704]=81, [705]=1, [706]=1, [709]=81, +[717]=1, [721]=81, [722]=81, [735]=1, [736]=1, [908]=1, [909]=1, [914]=81, +[959]=81, [962]=81, [969]=1, [971]=81, [1013]=81, [1014]=81, [1048]=81, [1049]=81, +[1050]=81, [1051]=1, [1053]=81, [1089]=1, [1098]=81, [1100]=1, [1101]=81, [1102]=81, +[1107]=1, [1109]=81, [1113]=81, [1139]=81, [1142]=81, [1144]=81, [1151]=1, [1160]=81, +[1166]=1, [1168]=1, [1172]=1, [1173]=1, [1193]=81, [1198]=81, [1199]=81, [1200]=81, +[1221]=81, [1275]=81, [1318]=81, [1360]=81, [1380]=1, [1381]=1, [1383]=1, [1424]=1, +[1442]=81, [1445]=81, [1446]=81, [1448]=1, [1475]=81, [1486]=81, [1487]=81, [1488]=1, +[1489]=81, [1490]=81, [1491]=81, [1653]=81, [1654]=81, [1655]=1, [1657]=41, [1658]=41, +[1701]=1, [1713]=1, [1740]=81, [1951]=81, [1955]=1, [1956]=81, [2040]=81, [2078]=1, +[2198]=81, [2199]=81, [2200]=81, [2201]=81, [2202]=81, [2203]=1, [2204]=81, [2240]=81, +[2278]=81, [2280]=81, [2283]=81, [2284]=81, [2318]=81, [2338]=81, [2339]=81, [2340]=81, +[2341]=81, [2342]=81, [2359]=1, [2361]=81, [2398]=81, [2418]=81, [2478]=1, [2499]=1, +[2501]=1, [2721]=1, [2768]=81, [2769]=81, [2770]=81, [2841]=81, [2842]=81, [2843]=81, +[2846]=81, [2861]=81, [2864]=81, [2865]=81, [2904]=81, [2922]=81, [2923]=81, [2924]=81, +[2925]=81, [2926]=81, [2927]=81, [2928]=81, [2929]=81, [2930]=81, [2931]=81, [2935]=1, +[2936]=81, [2937]=1, [2944]=1, [2945]=81, [2946]=1, [2947]=81, [2949]=81, [2951]=81, +[2952]=81, [2953]=81, [2954]=1, [2962]=81, [2966]=1, [2967]=1, [2977]=1, [2991]=81, +[2993]=1, [2994]=1, [3042]=81, [3062]=1, [3126]=1, [3127]=1, [3128]=1, [3181]=1, +[3182]=1, [3201]=1, [3341]=81, [3372]=1, [3373]=81, [3380]=81, [3385]=1, [3446]=81, +[3447]=81, [3452]=1, [3463]=1, [3510]=1, [3514]=1, [3523]=81, [3525]=81, [3527]=81, +[3528]=81, [3566]=1, [3602]=1, [3627]=1, [3628]=1, [3636]=81, [3801]=81, [3802]=81, +[3906]=81, [3907]=81, [3962]=1, [3981]=81, [3982]=81, [4001]=81, [4002]=81, [4003]=81, +[4004]=81, [4021]=1, [4023]=1, [4024]=81, [4063]=81, [4081]=81, [4082]=81, [4083]=81, +[4121]=81, [4122]=81, [4123]=81, [4126]=81, [4128]=81, [4132]=81, [4133]=81, [4134]=81, +[4136]=81, [4143]=81, [4146]=1, [4148]=81, [4182]=1, [4201]=81, [4241]=81, [4242]=81, +[4262]=81, [4263]=81, [4264]=81, [4282]=81, [4286]=81, [4295]=81, [4322]=81, [4324]=81, +[4341]=81, [4342]=81, [4361]=81, [4362]=81, [4363]=81, [4601]=81, [4602]=81, [4603]=81, +[4604]=81, [4605]=81, [4606]=81, [4701]=81, [4724]=81, [4729]=81, [4734]=62, [4735]=62, +[4742]=81, [4743]=81, [4764]=62, [4766]=62, [4768]=62, [4769]=62, [4771]=81, [4787]=1, +[4788]=81, [4862]=81, [4866]=81, [4867]=81, [4903]=81, [4907]=62, [4961]=1, [4974]=62, +[4981]=81, [4982]=81, [4983]=81, [5001]=81, [5002]=81, [5047]=81, [5054]=1, [5055]=1, +[5056]=1, [5063]=1, [5065]=1, [5067]=1, [5068]=1, [5081]=81, [5088]=1, [5089]=81, +[5098]=1, [5102]=62, [5103]=81, [5121]=1, [5122]=81, [5124]=1, [5125]=81, [5127]=81, +[5151]=1, [5153]=1, [5156]=1, [5160]=81, [5162]=1, [5164]=1, [5166]=1, [5167]=1, +[5168]=1, [5212]=81, [5213]=81, [5214]=81, [5243]=81, [5247]=1, [5251]=81, [5262]=81, +[5263]=81, [5264]=1, [5265]=1, [5281]=81, [5282]=81, [5305]=81, [5306]=81, [5307]=81, +[5341]=81, [5342]=41, [5343]=81, [5344]=41, [5382]=81, [5384]=81, [5461]=1, [5462]=1, +[5463]=81, [5464]=1, [5465]=1, [5466]=81, [5515]=81, [5518]=81, [5519]=81, [5522]=81, +[5525]=81, [5526]=81, [5528]=81, [5529]=81, [5531]=81, [5582]=81, [5721]=62, [5722]=81, +[5723]=81, [5724]=81, [5725]=81, [5728]=81, [5761]=81, [5803]=1, [5804]=1, [5848]=81, +[5862]=1, [5944]=1, [5981]=1, [6025]=1, [6041]=1, [6135]=1, [6136]=1, [6145]=1, +[6146]=1, [6147]=1, [6148]=1, [6163]=81, [6182]=1, [6183]=1, [6184]=1, [6185]=1, +[6186]=1, [6187]=62, [6283]=1, [6284]=1, [6402]=62, [6403]=62, [6481]=1, [6501]=62, +[6502]=62, [6521]=81, [6522]=81, [6561]=81, [6562]=81, [6563]=81, [6564]=81, [6565]=81, +[6569]=62, [6570]=1, [6582]=62, [6583]=62, [6584]=62, [6585]=62, [6602]=62, [6626]=81, +[6642]=81, [6643]=81, [6644]=81, [6645]=81, [6646]=81, [6781]=41, [6801]=41, [6821]=62, +[6822]=62, [6824]=62, [6825]=41, [6826]=41, [6827]=41, [6881]=41, [6901]=41, [6921]=81, +[6922]=81, [6941]=41, [6942]=41, [6943]=41, [6983]=1, [7001]=41, [7002]=41, [7026]=41, +[7027]=41, [7028]=81, [7029]=81, [7041]=81, [7043]=1, [7044]=81, [7046]=81, [7064]=81, +[7065]=81, [7066]=81, [7067]=81, [7068]=81, [7070]=81, [7081]=41, [7082]=41, [7101]=41, +[7102]=41, [7121]=41, [7122]=41, [7123]=41, [7124]=41, [7141]=41, [7142]=41, [7181]=62, +[7201]=81, [7202]=62, [7223]=41, [7224]=41, [7241]=41, [7261]=41, [7281]=62, [7282]=62, +[7301]=41, [7302]=41, [7361]=41, [7362]=41, [7363]=41, [7364]=41, [7365]=41, [7366]=41, +[7367]=62, [7368]=62, [7381]=62, [7382]=62, [7385]=41, [7386]=41, [7401]=41, [7402]=41, +[7421]=41, [7422]=41, [7423]=41, [7424]=41, [7425]=41, [7426]=41, [7427]=41, [7428]=41, +[7429]=81, [7441]=81, [7461]=81, [7462]=81, [7463]=81, [7478]=81, [7479]=81, [7480]=81, +[7481]=81, [7482]=81, [7483]=81, [7484]=81, [7485]=81, [7487]=81, [7488]=81, [7489]=81, +[7492]=81, [7494]=81, [7498]=81, [7499]=81, [7500]=81, [7501]=81, [7502]=81, [7503]=81, +[7504]=81, [7505]=81, [7506]=81, [7507]=81, [7508]=81, [7509]=62, [7521]=62, [7581]=81, +[7582]=1, [7583]=1, [7603]=1, [7604]=81, [7629]=81, [7631]=81, [7634]=62, [7635]=62, +[7636]=1, [7642]=81, [7643]=81, [7647]=81, [7649]=81, [7650]=81, [7651]=81, [7666]=81, +[7668]=81, [7701]=1, [7703]=81, [7737]=81, [7761]=81, [7786]=62, [7788]=41, [7789]=41, +[7810]=41, [7816]=1, [7841]=41, [7842]=41, [7843]=41, [7845]=1, [7846]=1, [7847]=1, +[7848]=81, [7849]=1, [7850]=1, [7861]=1, [7862]=1, [7863]=41, [7864]=41, [7865]=41, +[7866]=41, [7867]=41, [7868]=41, [7871]=41, [7872]=41, [7873]=41, [7874]=41, [7875]=41, +[7876]=41, [7877]=81, [7886]=41, [7887]=41, [7888]=41, [7921]=41, [7922]=41, [7923]=41, +[7924]=41, [7925]=41, [8001]=41, [8041]=62, [8042]=62, [8043]=62, [8044]=62, [8045]=62, +[8046]=62, [8047]=62, [8048]=62, [8049]=62, [8050]=62, [8051]=62, [8052]=62, [8053]=62, +[8054]=62, [8055]=62, [8056]=62, [8057]=62, [8058]=62, [8059]=62, [8060]=62, [8061]=62, +[8062]=62, [8063]=62, [8064]=62, [8065]=62, [8066]=62, [8067]=62, [8068]=62, [8069]=62, +[8070]=62, [8071]=62, [8072]=62, [8073]=62, [8074]=62, [8075]=62, [8076]=62, [8077]=62, +[8078]=62, [8079]=62, [8080]=41, [8081]=41, [8101]=62, [8102]=62, [8103]=62, [8104]=62, +[8105]=41, [8106]=62, [8107]=62, [8108]=62, [8109]=62, [8110]=62, [8111]=62, [8112]=62, +[8113]=62, [8114]=41, [8115]=41, [8116]=62, [8117]=62, [8118]=62, [8119]=62, [8120]=41, +[8121]=41, [8122]=41, [8123]=41, [8124]=41, [8141]=62, [8142]=62, [8143]=62, [8144]=62, +[8145]=62, [8146]=62, [8147]=62, [8148]=62, [8154]=41, [8155]=41, [8156]=41, [8157]=41, +[8158]=41, [8159]=41, [8160]=41, [8161]=41, [8162]=41, [8163]=41, [8164]=41, [8165]=41, +[8166]=41, [8167]=41, [8168]=41, [8169]=41, [8170]=41, [8171]=41, [8181]=62, [8182]=62, +[8184]=62, [8185]=62, [8186]=62, [8187]=62, [8188]=62, [8189]=62, [8190]=62, [8191]=62, +[8192]=62, [8201]=62, [8227]=62, [8232]=81, [8236]=81, [8253]=81, [8257]=81, [8258]=81, +[8260]=41, [8261]=41, [8262]=41, [8263]=41, [8264]=41, [8265]=41, [8271]=62, [8272]=62, +[8283]=1, [8288]=62, [8289]=41, [8290]=41, [8291]=41, [8292]=41, [8293]=41, [8294]=41, +[8295]=41, [8296]=41, [8297]=41, [8298]=41, [8299]=41, [8300]=41, [8301]=1, [8302]=1, +[8304]=1, [8306]=1, [8308]=1, [8309]=1, [8310]=1, [8314]=1, [8315]=62, [8316]=62, +[8322]=41, [8331]=1, [8332]=1, [8341]=1, [8348]=1, [8349]=1, [8351]=62, [8352]=62, +[8361]=1, [8362]=1, [8363]=1, [8364]=62, [8367]=41, [8368]=41, [8369]=41, [8370]=41, +[8371]=41, [8372]=41, [8373]=41, [8374]=41, [8375]=41, [8376]=62, [8377]=62, [8378]=62, +[8379]=62, [8380]=62, [8381]=62, [8382]=62, [8383]=41, [8384]=41, [8385]=41, [8386]=41, +[8387]=41, [8388]=41, [8389]=41, [8390]=41, [8391]=41, [8392]=41, [8393]=41, [8394]=41, +[8395]=41, [8396]=41, [8397]=41, [8398]=41, [8399]=41, [8400]=41, [8401]=41, [8402]=41, +[8403]=41, [8404]=41, [8405]=41, [8406]=41, [8407]=41, [8408]=41, [8409]=41, [8413]=81, +[8418]=81, [8422]=81, [8425]=81, [8426]=41, [8427]=41, [8428]=41, [8429]=41, [8430]=41, +[8431]=41, [8432]=41, [8433]=41, [8434]=41, [8435]=41, [8436]=41, [8437]=41, [8438]=41, +[8439]=41, [8440]=41, [8441]=41, [8442]=41, [8443]=41, [8481]=1, [8493]=41, [8495]=41, +[8498]=1, [8500]=41, [8501]=1, [8502]=1, [8504]=41, [8506]=41, [8507]=1, [8510]=41, +[8512]=41, [8514]=41, [8516]=41, [8518]=41, [8521]=41, [8523]=41, [8525]=41, [8527]=41, +[8529]=41, [8533]=41, [8534]=1, [8535]=1, [8536]=1, [8537]=1, [8538]=1, [8539]=1, +[8543]=41, [8544]=62, [8546]=41, [8550]=41, [8551]=1, [8554]=1, [8556]=62, [8557]=62, +[8558]=62, [8559]=62, [8560]=62, [8561]=62, [8562]=62, [8578]=62, [8579]=62, [8581]=41, +[8583]=41, [8585]=62, [8589]=41, [8591]=41, [8592]=62, [8593]=62, [8594]=62, [8595]=62, +[8596]=62, [8601]=41, [8602]=62, [8603]=62, [8605]=41, [8606]=1, [8608]=41, [8610]=41, +[8612]=41, [8614]=41, [8616]=41, [8620]=62, [8621]=62, [8622]=62, [8623]=62, [8624]=62, +[8625]=62, [8626]=62, [8627]=62, [8628]=62, [8629]=62, [8630]=62, [8631]=62, [8632]=62, +[8633]=62, [8634]=62, [8637]=62, [8638]=62, [8639]=62, [8640]=62, [8641]=62, [8655]=62, +[8656]=62, [8657]=62, [8658]=62, [8659]=62, [8660]=62, [8661]=62, [8662]=62, [8663]=62, +[8664]=62, [8665]=62, [8666]=62, [8667]=62, [8668]=62, [8669]=62, [8687]=1, [8689]=62, +[8690]=62, [8691]=62, [8692]=62, [8693]=62, [8694]=62, [8695]=62, [8696]=62, [8697]=62, +[8698]=62, [8699]=62, [8700]=62, [8701]=62, [8702]=62, [8703]=62, [8704]=62, [8705]=62, +[8706]=62, [8707]=62, [8708]=62, [8709]=62, [8710]=62, [8711]=62, [8712]=62, [8729]=62, +[8730]=62, [8731]=1, [8735]=62, [8736]=62, [8737]=1, [8738]=1, [8739]=1, [8740]=1, +[8743]=82, [8770]=1, [8771]=1, [8772]=1, [8773]=1, [8774]=1, [8775]=1, [8776]=1, +[8777]=1, [8789]=62, [8790]=62, [8791]=62, [8829]=1, [8846]=41, [8847]=41, [8849]=41, +[8850]=41, [8851]=41, [8852]=41, [8854]=41, [8855]=41, [8868]=62, [8905]=81, [8906]=81, +[8907]=81, [8908]=81, [8909]=81, [8910]=81, [8911]=81, [8912]=81, [8913]=81, [8914]=81, +[8915]=81, [8916]=81, [8917]=81, [8918]=81, [8919]=81, [8920]=81, [8926]=81, [8927]=81, +[8928]=1, [8931]=81, [8932]=81, [8933]=81, [8934]=81, [8935]=81, [8936]=81, [8937]=81, +[8938]=81, [8939]=81, [8940]=81, [8941]=81, [8942]=81, [8943]=81, [8944]=81, [8945]=81, +[8948]=81, [8949]=81, [8950]=81, [8951]=81, [8952]=81, [8953]=81, [8954]=81, [8955]=81, +[8956]=81, [8957]=81, [8958]=81, [8959]=81, [8961]=62, [8962]=1, [8963]=1, [8964]=1, +[8965]=1, [8966]=81, [8967]=81, [8968]=81, [8969]=81, [8970]=1, [8985]=1, [8986]=1, +[8987]=1, [8988]=1, [8989]=81, [8990]=81, [8991]=81, [8992]=81, [8994]=62, [8995]=62, +[8999]=81, [9000]=81, [9001]=81, [9002]=81, [9003]=81, [9004]=81, [9005]=81, [9006]=81, +[9007]=81, [9008]=81, [9009]=81, [9010]=81, [9011]=81, [9012]=81, [9013]=81, [9014]=81, +[9015]=81, [9016]=81, [9017]=81, [9018]=81, [9019]=81, [9020]=81, [9021]=81, [9022]=81, +[9023]=62, [9033]=62, [9034]=62, [9036]=62, [9037]=62, [9038]=62, [9039]=62, [9040]=62, +[9041]=62, [9042]=62, [9043]=62, [9044]=62, [9045]=62, [9046]=62, [9047]=62, [9048]=62, +[9049]=62, [9050]=62, [9051]=1, [9053]=81, [9054]=62, [9055]=62, [9056]=62, [9057]=62, +[9058]=62, [9059]=62, [9060]=62, [9061]=62, [9068]=62, [9069]=62, [9070]=62, [9071]=62, +[9072]=62, [9073]=62, [9074]=62, [9075]=62, [9077]=62, [9078]=62, [9079]=62, [9080]=62, +[9081]=62, [9082]=62, [9083]=62, [9084]=62, [9085]=1, [9086]=62, [9087]=62, [9088]=62, +[9089]=62, [9090]=62, [9091]=62, [9092]=62, [9093]=62, [9095]=62, [9096]=62, [9097]=62, +[9098]=62, [9099]=62, [9100]=62, [9101]=62, [9102]=62, [9103]=62, [9104]=62, [9105]=62, +[9106]=62, [9107]=62, [9108]=62, [9109]=62, [9110]=62, [9111]=62, [9112]=62, [9113]=62, +[9114]=62, [9115]=62, [9116]=62, [9117]=62, [9118]=62, [9208]=62, [9209]=62, [9210]=62, +[9229]=62, [9230]=62, [9232]=62, [9233]=62, [9234]=62, [9235]=62, [9236]=62, [9237]=62, +[9238]=62, [9239]=62, [9240]=62, [9241]=62, [9242]=62, [9243]=62, [9244]=62, [9245]=62, +[9246]=62, [9250]=62, [9251]=62, [9257]=81, [9269]=81, [9270]=81, [9271]=81, [9419]=41, +[9422]=41, [9664]=41, [9665]=41, +} +Codex.WdbLevel = { +[2]=30, [5]=20, [6]=5, [7]=2, [8]=5, [9]=15, [10]=48, [11]=10, +[12]=12, [13]=14, [14]=17, [15]=3, [16]=1, [17]=42, [18]=4, [19]=25, +[20]=21, [21]=5, [22]=12, [23]=24, [24]=27, [25]=25, [26]=16, [27]=16, +[28]=16, [29]=16, [30]=16, [31]=16, [32]=48, [33]=2, [34]=24, [35]=10, +[36]=10, [37]=10, [38]=13, [39]=10, [40]=10, [45]=10, [46]=10, [47]=7, +[48]=44, [49]=44, [50]=44, [51]=44, [52]=10, [53]=44, [54]=5, [55]=32, +[56]=24, [57]=26, [58]=30, [59]=10, [60]=7, [61]=7, [62]=7, [63]=23, +[64]=12, [65]=18, [66]=28, [67]=28, [68]=28, [69]=28, [70]=28, [71]=10, +[72]=28, [74]=28, [75]=28, [76]=10, [77]=48, [78]=28, [79]=28, [80]=28, +[81]=48, [82]=47, [83]=9, [84]=6, [85]=6, [86]=6, [87]=8, [88]=9, +[89]=20, [90]=25, [91]=23, [92]=18, [93]=20, [94]=21, [95]=25, [96]=23, +[97]=28, [98]=35, [99]=15, [100]=23, [101]=25, [102]=14, [103]=16, [104]=20, +[105]=60, [106]=6, [107]=6, [109]=10, [110]=48, [111]=6, [112]=7, [113]=48, +[114]=7, [115]=23, [116]=15, [117]=15, [118]=18, [119]=18, [120]=14, [121]=14, +[122]=18, [123]=10, [124]=20, [125]=16, [126]=25, [127]=21, [128]=25, [129]=15, +[130]=15, [131]=15, [132]=18, [133]=27, [134]=30, [135]=18, [136]=16, [138]=16, +[139]=16, [140]=16, [141]=18, [142]=18, [143]=14, [144]=14, [145]=18, [146]=18, +[147]=10, [148]=24, [149]=24, [150]=20, [151]=14, [152]=19, [153]=15, [154]=24, +[155]=18, [156]=24, [157]=24, [158]=24, [159]=24, [160]=30, [161]=18, [162]=49, +[163]=20, [164]=23, [165]=25, [166]=22, [167]=20, [168]=18, [169]=26, [170]=2, +[171]=60, [172]=60, [173]=28, [174]=25, [175]=25, [176]=11, [177]=25, [178]=23, +[179]=1, [180]=26, [181]=30, [182]=4, [183]=3, [184]=9, [185]=31, [186]=33, +[187]=35, [188]=37, [189]=35, [190]=31, [191]=33, [192]=38, [193]=40, [194]=34, +[195]=36, [196]=41, [197]=43, [198]=32, [199]=18, [200]=35, [201]=32, [202]=40, +[203]=33, [204]=34, [205]=40, [206]=46, [207]=38, [208]=43, [209]=42, [210]=37, +[211]=60, [212]=40, [213]=36, [214]=17, [215]=33, [216]=24, [217]=17, [218]=5, +[219]=25, [220]=23, [221]=29, [222]=31, [223]=31, [224]=12, [225]=35, [226]=21, +[227]=35, [228]=35, [229]=35, [230]=25, [231]=35, [232]=45, [233]=3, [234]=4, +[235]=20, [236]=21, [237]=15, [238]=45, [239]=10, [240]=20, [243]=46, [244]=16, +[245]=21, [246]=17, [247]=30, [248]=22, [249]=27, [250]=18, [251]=30, [252]=30, +[253]=30, [254]=35, [255]=19, [256]=22, [257]=16, [258]=17, [261]=39, [262]=25, +[263]=15, [264]=15, [265]=25, [266]=25, [267]=12, [268]=25, [269]=29, [270]=29, +[271]=20, [272]=16, [273]=15, [274]=18, [275]=26, [276]=21, [277]=23, [278]=18, +[279]=22, [280]=18, [281]=25, [282]=5, [283]=20, [284]=25, [285]=25, [286]=25, +[287]=9, [288]=27, [289]=29, [290]=30, [291]=10, [292]=30, [293]=30, [294]=24, +[295]=27, [296]=29, [297]=18, [298]=15, [299]=28, [301]=15, [302]=15, [303]=30, +[304]=34, [305]=24, [306]=24, [307]=15, [308]=7, [309]=15, [310]=6, [311]=7, +[312]=12, [313]=7, [314]=12, [315]=9, [317]=6, [318]=7, [319]=8, [320]=8, +[321]=29, [322]=29, [323]=28, [324]=29, [325]=29, [328]=37, [329]=37, [330]=37, +[331]=37, [332]=2, [333]=2, [334]=2, [335]=30, [336]=30, [337]=25, [338]=40, +[339]=40, [340]=40, [341]=40, [342]=40, [343]=24, [344]=24, [345]=24, [346]=24, +[347]=24, [348]=45, [349]=35, [350]=31, [351]=48, [353]=15, [354]=11, [355]=10, +[356]=11, [357]=8, [358]=8, [359]=9, [360]=9, [361]=7, [362]=10, [363]=1, +[364]=2, [365]=7, [366]=8, [367]=6, [368]=9, [369]=11, [370]=9, [371]=10, +[372]=12, [373]=22, [374]=7, [375]=8, [376]=2, [377]=26, [378]=27, [379]=46, +[380]=4, [381]=4, [382]=5, [383]=5, [384]=7, [385]=15, [386]=25, [387]=26, +[388]=26, [389]=22, [391]=29, [392]=29, [393]=29, [394]=31, [395]=31, [396]=31, +[397]=30, [398]=10, [399]=15, [400]=5, [401]=30, [403]=1, [404]=6, [405]=8, +[407]=7, [408]=13, [409]=12, [410]=10, [411]=12, [412]=10, [413]=10, [414]=10, +[415]=10, [416]=11, [417]=11, [418]=11, [419]=10, [420]=5, [421]=10, [422]=11, +[423]=14, [424]=15, [425]=12, [426]=8, [427]=8, [428]=12, [429]=11, [430]=11, +[431]=10, [432]=9, [433]=11, [434]=31, [435]=11, [436]=18, [437]=14, [438]=16, +[439]=16, [440]=16, [441]=16, [442]=24, [443]=17, [444]=17, [445]=10, [446]=16, +[447]=12, [448]=16, [449]=11, [450]=15, [451]=18, [452]=15, [453]=25, [454]=15, +[455]=21, [456]=2, [457]=3, [458]=1, [459]=3, [460]=17, [461]=18, [463]=21, +[464]=28, [465]=31, [466]=22, [467]=23, [468]=21, [469]=21, [470]=24, [471]=26, +[472]=25, [473]=28, [474]=32, [475]=6, [476]=6, [477]=14, [478]=14, [479]=16, +[480]=22, [481]=14, [482]=14, [483]=9, [484]=22, [485]=48, [486]=12, [487]=8, +[488]=5, [489]=7, [491]=18, [492]=11, [493]=20, [494]=20, [495]=39, [496]=22, +[498]=22, [499]=22, [500]=36, [501]=24, [502]=24, [503]=36, [504]=40, [505]=33, +[506]=36, [507]=42, [508]=40, [509]=28, [510]=34, [511]=34, [512]=36, [513]=28, +[514]=34, [515]=30, [516]=21, [517]=30, [518]=39, [519]=41, [520]=43, [521]=43, +[522]=38, [523]=40, [524]=30, [525]=34, [526]=29, [527]=24, [528]=25, [529]=26, +[530]=20, [531]=20, [532]=26, [533]=34, [535]=34, [536]=30, [537]=40, [538]=38, +[539]=28, [540]=38, [541]=30, [542]=38, [543]=40, [544]=34, [545]=35, [546]=25, +[547]=30, [549]=22, [550]=32, [551]=40, [552]=33, [553]=33, [554]=40, [555]=31, +[556]=32, [557]=34, [558]=60, [559]=32, [560]=32, [561]=32, [562]=32, [563]=32, +[564]=34, [565]=34, [566]=40, [567]=28, [568]=36, [569]=37, [570]=38, [571]=41, +[572]=41, [573]=44, [574]=38, [575]=31, [576]=42, [577]=36, [578]=37, [579]=1, +[580]=50, [581]=34, [582]=37, [583]=30, [584]=41, [585]=40, [586]=46, [587]=41, +[588]=45, [589]=45, [590]=5, [591]=46, [592]=46, [593]=46, [594]=45, [595]=41, +[596]=37, [597]=41, [598]=42, [599]=41, [600]=41, [601]=37, [602]=37, [603]=37, +[604]=43, [605]=35, [606]=41, [607]=41, [608]=45, [609]=44, [610]=39, [611]=40, +[613]=44, [614]=47, [615]=50, [616]=37, [617]=43, [618]=50, [619]=52, [621]=44, +[622]=37, [623]=43, [624]=43, [625]=43, [626]=51, [627]=37, [628]=38, [629]=37, +[630]=51, [631]=31, [632]=31, [633]=31, [634]=31, [635]=35, [636]=35, [637]=30, +[638]=37, [639]=37, [640]=40, [641]=40, [642]=37, [643]=41, [644]=42, [645]=42, +[646]=42, [647]=30, [648]=48, [649]=48, [650]=48, [651]=38, [652]=42, [653]=40, +[654]=46, [655]=34, [656]=50, [657]=36, [658]=36, [659]=33, [660]=37, [661]=37, +[662]=40, [663]=35, [664]=40, [665]=40, [666]=40, [667]=44, [668]=40, [669]=40, +[670]=40, [671]=33, [672]=34, [673]=40, [674]=34, [675]=34, [676]=32, [677]=32, +[678]=38, [679]=40, [680]=40, [681]=31, [682]=37, [683]=30, [684]=39, [685]=40, +[686]=30, [687]=40, [688]=40, [689]=31, [690]=32, [691]=36, [692]=41, [693]=39, +[694]=39, [695]=39, [696]=39, [697]=39, [698]=40, [699]=42, [700]=31, [701]=37, +[702]=37, [703]=40, [704]=38, [705]=37, [706]=45, [707]=37, [708]=40, [709]=40, +[710]=37, [711]=39, [712]=42, [713]=37, [714]=37, [715]=37, [716]=42, [717]=50, +[718]=38, [719]=35, [720]=35, [721]=35, [722]=40, [723]=40, [724]=40, [725]=40, +[726]=40, [727]=40, [728]=40, [729]=20, [730]=14, [731]=20, [732]=43, [733]=40, +[734]=42, [735]=44, [736]=44, [737]=40, [738]=38, [739]=42, [741]=20, [742]=20, +[743]=8, [744]=11, [745]=6, [746]=8, [747]=2, [748]=5, [749]=8, [750]=3, +[751]=8, [752]=2, [753]=3, [754]=6, [755]=3, [756]=7, [757]=4, [758]=8, +[759]=10, [760]=10, [804]=5, [805]=5, [806]=12, [807]=10, [808]=9, [809]=13, +[810]=5, [811]=8, [812]=9, [813]=9, [814]=6, [815]=8, [816]=11, [817]=8, +[818]=7, [819]=15, [820]=10, [821]=15, [822]=24, [823]=7, [824]=27, [825]=8, +[826]=10, [827]=12, [828]=12, [829]=12, [830]=7, [831]=7, [832]=12, [833]=10, +[834]=9, [835]=11, [836]=48, [837]=10, [838]=55, [840]=12, [841]=46, [842]=12, +[843]=23, [844]=12, [845]=13, [846]=26, [847]=37, [848]=15, [849]=26, [850]=16, +[851]=18, [852]=19, [853]=15, [854]=12, [855]=14, [857]=30, [858]=18, [860]=10, +[861]=10, [862]=23, [863]=18, [864]=46, [865]=18, [866]=16, [867]=15, [868]=22, +[869]=13, [870]=13, [871]=12, [872]=15, [873]=27, [874]=27, [875]=16, [876]=20, +[877]=16, [878]=21, [879]=25, [880]=16, [881]=16, [882]=19, [883]=22, [884]=24, +[885]=25, [886]=10, [887]=14, [888]=16, [889]=20, [890]=14, [891]=20, [892]=14, +[893]=24, [894]=14, [895]=16, [896]=18, [897]=24, [898]=20, [899]=20, [900]=14, +[901]=14, [902]=16, [903]=15, [905]=17, [906]=25, [907]=18, [908]=27, [909]=30, +[910]=60, [911]=60, [913]=20, [914]=22, [915]=60, [916]=4, [917]=5, [918]=7, +[919]=7, [920]=5, [921]=5, [922]=7, [923]=9, [924]=14, [925]=60, [926]=14, +[927]=12, [928]=5, [929]=5, [930]=10, [931]=10, [932]=7, [933]=9, [934]=11, +[935]=11, [936]=50, [937]=11, [938]=12, [939]=54, [940]=11, [941]=12, [942]=20, +[943]=24, [944]=17, [945]=18, [947]=17, [948]=17, [949]=17, [950]=17, [951]=20, +[952]=11, [953]=12, [954]=12, [955]=12, [956]=13, [957]=13, [958]=12, [959]=18, +[960]=1, [961]=1, [962]=18, [963]=16, [964]=57, [965]=18, [966]=18, [967]=18, +[968]=20, [969]=60, [970]=21, [971]=23, [972]=20, [973]=24, [974]=55, [975]=60, +[976]=24, [977]=58, [978]=55, [979]=57, [980]=55, [981]=31, [982]=17, [983]=10, +[984]=14, [985]=14, [986]=20, [990]=19, [991]=19, [992]=46, [993]=20, [994]=22, +[995]=20, [996]=55, [997]=5, [998]=55, [1000]=55, [1001]=12, [1002]=14, [1003]=16, +[1004]=55, [1007]=20, [1008]=19, [1009]=25, [1010]=20, [1011]=29, [1012]=32, [1013]=26, +[1014]=27, [1015]=55, [1016]=24, [1017]=25, [1018]=55, [1019]=55, [1020]=20, [1021]=32, +[1022]=30, [1023]=21, [1024]=21, [1025]=24, [1026]=27, [1027]=28, [1028]=28, [1029]=28, +[1030]=28, [1031]=32, [1032]=32, [1033]=22, [1034]=23, [1035]=30, [1036]=60, [1037]=30, +[1038]=30, [1039]=30, [1040]=30, [1041]=30, [1042]=30, [1043]=30, [1044]=30, [1045]=30, +[1046]=30, [1047]=55, [1048]=42, [1049]=38, [1050]=38, [1051]=33, [1052]=40, [1053]=40, +[1054]=25, [1055]=28, [1056]=18, [1057]=27, [1058]=26, [1059]=27, [1060]=20, [1061]=17, +[1062]=19, [1063]=18, [1064]=18, [1065]=18, [1066]=23, [1067]=23, [1068]=23, [1069]=20, +[1070]=21, [1071]=21, [1072]=21, [1073]=21, [1074]=21, [1075]=21, [1076]=21, [1077]=21, +[1078]=26, [1079]=22, [1080]=22, [1081]=28, [1082]=22, [1083]=26, [1084]=28, [1085]=21, +[1086]=23, [1087]=25, [1088]=29, [1089]=29, [1090]=22, [1091]=22, [1092]=22, [1093]=21, +[1094]=21, [1095]=27, [1096]=27, [1097]=15, [1098]=25, [1100]=34, [1101]=34, [1102]=34, +[1103]=23, [1104]=30, [1105]=30, [1106]=35, [1107]=35, [1108]=39, [1109]=33, [1110]=31, +[1111]=36, [1112]=36, [1113]=33, [1114]=36, [1115]=36, [1116]=36, [1117]=36, [1118]=43, +[1119]=44, [1120]=44, [1121]=44, [1122]=44, [1123]=55, [1124]=55, [1125]=55, [1126]=57, +[1127]=44, [1130]=30, [1131]=30, [1132]=20, [1133]=20, [1134]=21, [1135]=30, [1136]=37, +[1137]=38, [1138]=17, [1139]=45, [1140]=28, [1141]=14, [1142]=30, [1143]=31, [1144]=30, +[1145]=33, [1146]=33, [1147]=35, [1148]=35, [1149]=26, [1150]=30, [1151]=30, [1152]=30, +[1153]=29, [1154]=30, [1157]=60, [1158]=60, [1159]=30, [1160]=36, [1164]=36, [1166]=43, +[1167]=28, [1168]=43, [1169]=43, [1170]=43, [1171]=43, [1172]=45, [1173]=45, [1175]=33, +[1176]=30, [1177]=36, [1178]=37, [1179]=30, [1180]=37, [1181]=37, [1182]=37, [1183]=37, +[1184]=35, [1185]=57, [1186]=37, [1187]=41, [1188]=41, [1189]=41, [1190]=41, [1191]=41, +[1192]=42, [1193]=60, [1194]=41, [1195]=25, [1196]=29, [1197]=29, [1198]=24, [1199]=25, +[1200]=27, [1201]=35, [1202]=35, [1203]=35, [1204]=38, [1205]=45, [1206]=35, [1218]=35, +[1219]=35, [1220]=35, [1221]=26, [1222]=37, [1238]=35, [1239]=35, [1240]=35, [1241]=28, +[1242]=28, [1243]=28, [1244]=30, [1245]=30, [1246]=31, [1247]=31, [1248]=33, [1249]=33, +[1250]=33, [1251]=35, [1252]=40, [1253]=35, [1258]=40, [1259]=40, [1260]=38, [1261]=40, +[1262]=40, [1264]=33, [1265]=35, [1266]=36, [1267]=38, [1268]=35, [1269]=37, [1270]=37, +[1271]=37, [1273]=37, [1274]=28, [1275]=24, [1276]=37, [1282]=35, [1284]=35, [1285]=38, +[1286]=38, [1287]=38, [1288]=38, [1289]=38, [1301]=35, [1302]=35, [1318]=60, [1319]=35, +[1320]=35, [1321]=35, [1322]=37, [1323]=37, [1324]=38, [1338]=14, [1339]=15, [1358]=15, +[1359]=15, [1360]=43, [1361]=32, [1362]=32, [1363]=41, [1364]=41, [1365]=35, [1366]=31, +[1367]=33, [1368]=33, [1369]=33, [1370]=35, [1371]=35, [1372]=42, [1373]=37, [1374]=37, +[1375]=37, [1380]=42, [1381]=42, [1382]=35, [1383]=42, [1384]=32, [1385]=35, [1386]=32, +[1387]=31, [1388]=42, [1389]=35, [1390]=60, [1391]=42, [1392]=39, [1393]=38, [1394]=36, +[1395]=45, [1396]=37, [1398]=42, [1418]=35, [1419]=40, [1420]=40, [1421]=35, [1422]=45, +[1423]=40, [1424]=43, [1425]=42, [1426]=43, [1427]=43, [1428]=45, [1429]=44, [1430]=44, +[1431]=30, [1432]=30, [1433]=33, [1434]=33, [1435]=33, [1436]=33, [1437]=33, [1438]=33, +[1439]=33, [1440]=33, [1442]=22, [1444]=44, [1445]=50, [1446]=53, [1447]=31, [1448]=43, +[1449]=43, [1450]=43, [1451]=43, [1452]=43, [1453]=33, [1454]=39, [1455]=39, [1456]=39, +[1457]=39, [1458]=33, [1459]=35, [1462]=4, [1463]=4, [1464]=13, [1465]=33, [1466]=40, +[1467]=40, [1468]=60, [1469]=43, [1470]=3, [1471]=10, [1472]=20, [1473]=10, [1474]=20, +[1475]=50, [1476]=20, [1477]=45, [1478]=10, [1479]=60, [1480]=33, [1481]=33, [1482]=35, +[1483]=21, [1484]=33, [1485]=4, [1486]=17, [1487]=21, [1488]=40, [1489]=16, [1490]=16, +[1491]=18, [1492]=11, [1498]=10, [1499]=4, [1501]=11, [1502]=10, [1503]=10, [1504]=11, +[1505]=10, [1506]=10, [1507]=20, [1508]=20, [1509]=20, [1510]=20, [1511]=20, [1512]=20, +[1513]=20, [1514]=55, [1515]=20, [1516]=4, [1517]=4, [1518]=4, [1519]=4, [1520]=4, +[1521]=4, [1522]=10, [1523]=10, [1524]=11, [1525]=12, [1526]=13, [1527]=13, [1528]=20, +[1529]=20, [1530]=22, [1531]=30, [1532]=30, [1534]=23, [1535]=22, [1536]=22, [1558]=60, +[1559]=37, [1560]=50, [1578]=12, [1579]=12, [1580]=12, [1581]=8, [1582]=18, [1638]=10, +[1639]=10, [1640]=10, [1641]=12, [1642]=12, [1643]=12, [1644]=13, [1645]=12, [1646]=12, +[1647]=12, [1648]=13, [1649]=20, [1650]=23, [1651]=25, [1652]=25, [1653]=21, [1654]=22, +[1655]=22, [1656]=5, [1657]=60, [1658]=60, [1661]=40, [1665]=10, [1666]=10, [1667]=10, +[1678]=11, [1679]=10, [1680]=11, [1681]=11, [1682]=10, [1683]=10, [1684]=10, [1685]=10, +[1686]=10, [1687]=60, [1688]=10, [1689]=10, [1690]=43, [1691]=44, [1692]=10, [1693]=10, +[1698]=20, [1699]=22, [1700]=28, [1701]=28, [1702]=22, [1703]=28, [1704]=28, [1705]=28, +[1706]=30, [1707]=44, [1708]=29, [1709]=30, [1710]=30, [1711]=30, [1712]=40, [1713]=40, +[1714]=37, [1715]=10, [1716]=20, [1717]=20, [1718]=30, [1719]=30, [1738]=20, [1739]=20, +[1740]=25, [1758]=30, [1778]=13, [1779]=13, [1780]=13, [1781]=13, [1782]=28, [1783]=13, +[1784]=13, [1785]=13, [1786]=13, [1787]=13, [1788]=13, [1789]=12, [1790]=12, [1791]=30, +[1792]=40, [1793]=20, [1794]=20, [1795]=30, [1796]=31, [1798]=30, [1799]=40, [1800]=60, +[1801]=30, [1802]=30, [1803]=30, [1804]=30, [1805]=30, [1806]=22, [1818]=10, [1819]=10, +[1820]=10, [1821]=11, [1822]=11, [1823]=20, [1824]=20, [1825]=20, [1838]=30, [1839]=30, +[1840]=30, [1841]=30, [1842]=30, [1843]=30, [1844]=30, [1845]=30, [1846]=30, [1847]=30, +[1848]=30, [1858]=13, [1859]=10, [1860]=10, [1861]=10, [1878]=44, [1879]=10, [1880]=10, +[1881]=10, [1882]=10, [1883]=10, [1884]=10, [1885]=10, [1886]=13, [1898]=13, [1899]=13, +[1918]=27, [1919]=15, [1920]=16, [1921]=15, [1938]=28, [1939]=26, [1940]=26, [1941]=15, +[1942]=26, [1943]=26, [1944]=26, [1945]=26, [1946]=26, [1947]=38, [1948]=40, [1949]=38, +[1950]=30, [1951]=40, [1952]=40, [1953]=40, [1954]=40, [1955]=40, [1956]=40, [1957]=40, +[1958]=40, [1959]=15, [1960]=16, [1961]=15, [1962]=15, [1963]=13, [1978]=13, [1998]=16, +[1999]=20, [2038]=15, [2039]=15, [2040]=20, [2041]=15, [2078]=20, [2098]=20, [2118]=14, +[2138]=16, [2139]=18, [2158]=5, [2159]=5, [2160]=5, [2161]=5, [2178]=12, [2198]=41, +[2199]=41, [2200]=42, [2201]=43, [2202]=42, [2203]=44, [2204]=44, [2205]=10, [2206]=10, +[2218]=10, [2238]=10, [2239]=10, [2240]=40, [2241]=10, [2242]=10, [2258]=39, [2259]=16, +[2260]=16, [2278]=47, [2279]=47, [2280]=47, [2281]=16, [2282]=20, [2283]=41, [2284]=41, +[2298]=16, [2299]=16, [2300]=16, [2318]=42, [2338]=42, [2339]=44, [2340]=44, [2341]=44, +[2342]=43, [2358]=22, [2359]=24, [2360]=20, [2361]=44, [2378]=16, [2379]=16, [2380]=16, +[2381]=18, [2382]=16, [2383]=1, [2398]=40, [2399]=10, [2418]=36, [2438]=6, [2439]=47, +[2440]=47, [2458]=20, [2459]=8, [2460]=20, [2478]=24, [2479]=26, [2480]=20, [2498]=9, +[2499]=9, [2500]=39, [2501]=44, [2518]=12, [2519]=10, [2520]=12, [2521]=55, [2522]=55, +[2523]=55, [2541]=8, [2561]=10, [2581]=50, [2582]=50, [2583]=50, [2584]=50, [2585]=50, +[2586]=50, [2601]=50, [2602]=50, [2603]=50, [2604]=50, [2605]=49, [2606]=49, [2607]=20, +[2608]=20, [2609]=20, [2621]=50, [2622]=50, [2623]=55, [2641]=49, [2661]=49, [2662]=49, +[2681]=57, [2701]=57, [2702]=57, [2721]=58, [2741]=47, [2742]=47, [2743]=60, [2744]=60, +[2745]=31, [2746]=31, [2747]=60, [2748]=60, [2749]=60, [2750]=60, [2751]=32, [2752]=32, +[2753]=36, [2754]=36, [2755]=36, [2756]=40, [2757]=40, [2758]=40, [2759]=40, [2760]=40, +[2761]=45, [2762]=45, [2763]=45, [2764]=45, [2765]=45, [2766]=45, [2767]=45, [2768]=47, +[2769]=46, [2770]=50, [2771]=45, [2772]=45, [2773]=45, [2781]=46, [2782]=47, [2783]=57, +[2784]=50, [2801]=57, [2821]=46, [2822]=46, [2841]=35, [2842]=35, [2843]=35, [2844]=49, +[2845]=49, [2846]=46, [2847]=45, [2848]=45, [2849]=45, [2850]=45, [2851]=45, [2852]=45, +[2853]=45, [2854]=45, [2855]=45, [2856]=45, [2857]=45, [2858]=45, [2859]=45, [2860]=45, +[2861]=46, [2862]=42, [2863]=43, [2864]=45, [2865]=45, [2866]=43, [2867]=43, [2869]=43, +[2870]=45, [2871]=45, [2872]=45, [2873]=45, [2874]=45, [2875]=45, [2876]=45, [2877]=48, +[2878]=55, [2879]=50, [2880]=45, [2881]=45, [2882]=45, [2902]=43, [2903]=43, [2904]=30, +[2922]=26, [2923]=26, [2924]=30, [2925]=30, [2926]=27, [2927]=27, [2928]=30, [2929]=35, +[2930]=30, [2931]=28, [2932]=42, [2933]=43, [2934]=45, [2935]=45, [2936]=45, [2937]=55, +[2938]=55, [2939]=47, [2940]=47, [2941]=48, [2942]=50, [2943]=48, [2944]=48, [2945]=34, +[2946]=50, [2947]=34, [2948]=35, [2949]=34, [2950]=35, [2951]=30, [2952]=30, [2953]=30, +[2954]=50, [2962]=30, [2963]=50, [2964]=50, [2965]=50, [2966]=50, [2967]=50, [2968]=50, +[2969]=47, [2970]=47, [2972]=47, [2973]=45, [2974]=45, [2975]=43, [2976]=45, [2977]=50, +[2978]=43, [2979]=46, [2980]=44, [2981]=43, [2982]=44, [2983]=10, [2984]=10, [2985]=20, +[2986]=20, [2987]=43, [2988]=45, [2989]=48, [2990]=47, [2991]=47, [2992]=47, [2993]=47, +[2994]=51, [2995]=47, [2996]=30, [2997]=12, [2998]=12, [2999]=12, [3000]=12, [3001]=30, +[3002]=47, [3022]=47, [3042]=45, [3062]=50, [3063]=50, [3065]=1, [3082]=1, [3083]=1, +[3084]=1, [3085]=1, [3086]=1, [3087]=1, [3088]=1, [3089]=1, [3090]=1, [3091]=1, +[3092]=1, [3093]=1, [3094]=1, [3095]=1, [3096]=1, [3097]=1, [3098]=1, [3099]=1, +[3100]=1, [3101]=1, [3102]=1, [3103]=1, [3104]=1, [3105]=1, [3106]=1, [3107]=1, +[3108]=1, [3109]=1, [3110]=1, [3111]=1, [3112]=1, [3113]=1, [3114]=1, [3115]=1, +[3116]=1, [3117]=1, [3118]=1, [3119]=1, [3120]=1, [3121]=45, [3122]=45, [3123]=47, +[3124]=47, [3125]=45, [3126]=50, [3127]=50, [3128]=50, [3129]=50, [3130]=43, [3141]=57, +[3161]=48, [3181]=48, [3182]=48, [3201]=48, [3221]=12, [3281]=18, [3301]=15, [3321]=50, +[3341]=42, [3361]=3, [3362]=50, [3363]=55, [3364]=5, [3365]=5, [3366]=25, [3367]=48, +[3368]=48, [3369]=25, [3370]=25, [3371]=55, [3372]=52, [3373]=55, [3374]=55, [3375]=42, +[3376]=5, [3377]=50, [3378]=50, [3379]=50, [3380]=51, [3381]=48, [3382]=57, [3385]=50, +[3402]=50, [3421]=55, [3441]=48, [3442]=48, [3443]=48, [3444]=51, [3445]=51, [3446]=51, +[3447]=51, [3448]=52, [3449]=52, [3450]=52, [3451]=52, [3452]=50, [3453]=50, [3454]=50, +[3461]=52, [3462]=50, [3463]=52, [3481]=50, [3482]=35, [3483]=52, [3501]=55, [3502]=55, +[3503]=55, [3504]=53, [3505]=53, [3506]=56, [3507]=56, [3508]=58, [3509]=58, [3510]=58, +[3511]=58, [3512]=55, [3513]=25, [3514]=29, [3517]=52, [3518]=52, [3519]=4, [3520]=44, +[3521]=4, [3522]=4, [3523]=37, [3524]=13, [3525]=37, [3526]=47, [3527]=47, [3528]=53, +[3541]=52, [3542]=52, [3561]=52, [3562]=52, [3563]=52, [3564]=52, [3565]=52, [3566]=52, +[3567]=25, [3568]=52, [3569]=52, [3570]=52, [3578]=45, [3579]=45, [3580]=45, [3581]=60, +[3601]=53, [3602]=58, [3621]=58, [3625]=58, [3626]=58, [3627]=60, [3628]=60, [3629]=47, +[3630]=47, [3631]=40, [3632]=47, [3633]=47, [3634]=47, [3635]=47, [3636]=42, [3637]=47, +[3638]=47, [3639]=47, [3640]=47, [3641]=47, [3642]=47, [3643]=47, [3644]=47, [3645]=47, +[3646]=47, [3647]=47, [3661]=47, [3681]=12, [3701]=54, [3702]=54, [3721]=50, [3741]=15, +[3761]=50, [3762]=50, [3763]=50, [3764]=50, [3765]=24, [3781]=50, [3782]=50, [3783]=56, +[3784]=50, [3785]=50, [3786]=50, [3787]=50, [3788]=50, [3789]=50, [3790]=50, [3791]=50, +[3792]=55, [3801]=52, [3802]=52, [3803]=55, [3804]=55, [3821]=52, [3822]=53, [3823]=52, +[3824]=53, [3825]=53, [3841]=47, [3842]=47, [3843]=47, [3844]=52, [3845]=52, [3861]=1, +[3881]=53, [3882]=51, [3883]=52, [3884]=50, [3901]=3, [3902]=3, [3903]=4, [3904]=4, +[3905]=4, [3906]=52, [3907]=56, [3908]=52, [3909]=52, [3912]=52, [3913]=52, [3914]=52, +[3921]=14, [3922]=15, [3923]=18, [3924]=19, [3941]=52, [3942]=54, [3961]=54, [3962]=56, +[3981]=52, [3982]=54, [4001]=54, [4002]=54, [4003]=59, [4004]=60, [4005]=54, [4021]=20, +[4022]=54, [4023]=54, [4024]=58, [4041]=52, [4061]=54, [4062]=54, [4063]=58, [4081]=52, +[4082]=54, [4083]=55, [4084]=54, [4101]=55, [4102]=55, [4103]=55, [4104]=55, [4105]=55, +[4106]=55, [4107]=55, [4108]=55, [4109]=55, [4110]=55, [4111]=55, [4112]=55, [4113]=55, +[4114]=55, [4115]=55, [4116]=55, [4117]=55, [4118]=55, [4119]=55, [4120]=52, [4121]=58, +[4122]=58, [4123]=55, [4124]=43, [4125]=43, [4126]=55, [4127]=44, [4128]=55, [4129]=44, +[4130]=44, [4131]=44, [4132]=58, [4133]=55, [4134]=55, [4135]=46, [4136]=53, [4141]=52, +[4142]=52, [4143]=52, [4144]=53, [4145]=52, [4146]=52, [4147]=52, [4148]=53, [4161]=7, +[4181]=47, [4182]=54, [4183]=54, [4184]=54, [4185]=54, [4186]=54, [4201]=54, [4221]=55, +[4222]=55, [4223]=54, [4224]=54, [4241]=54, [4242]=54, [4243]=53, [4244]=53, [4245]=53, +[4261]=56, [4262]=52, [4263]=56, [4264]=58, [4265]=46, [4266]=46, [4267]=46, [4281]=44, +[4282]=58, [4283]=56, [4284]=53, [4285]=53, [4286]=56, [4287]=53, [4288]=53, [4289]=55, +[4290]=53, [4291]=53, [4292]=56, [4293]=52, [4294]=56, [4295]=1, [4296]=50, [4297]=47, +[4298]=48, [4300]=52, [4301]=55, [4321]=53, [4322]=58, [4324]=53, [4341]=59, [4342]=59, +[4343]=55, [4361]=59, [4362]=59, [4363]=59, [4381]=53, [4382]=53, [4383]=53, [4384]=53, +[4385]=53, [4386]=53, [4401]=55, [4402]=3, [4403]=55, [4421]=54, [4441]=54, [4442]=54, +[4443]=55, [4444]=55, [4445]=55, [4446]=55, [4447]=55, [4448]=55, [4449]=45, [4450]=46, +[4451]=47, [4461]=55, [4462]=55, [4463]=55, [4464]=55, [4465]=55, [4466]=55, [4467]=55, +[4481]=55, [4482]=55, [4483]=55, [4484]=55, [4485]=40, [4486]=40, [4487]=40, [4488]=40, +[4489]=40, [4490]=40, [4491]=55, [4492]=55, [4493]=53, [4494]=53, [4495]=4, [4496]=53, +[4501]=55, [4502]=55, [4503]=51, [4504]=54, [4505]=54, [4506]=54, [4507]=54, [4508]=54, +[4509]=54, [4510]=54, [4511]=54, [4512]=52, [4513]=54, [4521]=56, [4542]=25, [4561]=52, +[4581]=29, [4601]=30, [4602]=30, [4603]=30, [4604]=30, [4605]=30, [4606]=30, [4621]=60, +[4641]=1, [4642]=55, [4661]=52, [4681]=14, [4701]=59, [4721]=59, [4722]=13, [4723]=13, +[4724]=59, [4725]=15, [4726]=52, [4727]=15, [4728]=14, [4729]=59, [4730]=16, [4731]=19, +[4732]=19, [4733]=19, [4734]=60, [4735]=60, [4736]=31, [4737]=31, [4738]=31, [4739]=31, +[4740]=18, [4741]=58, [4742]=60, [4743]=60, [4761]=15, [4762]=15, [4763]=18, [4764]=60, +[4765]=60, [4766]=60, [4767]=29, [4768]=60, [4769]=60, [4770]=29, [4771]=60, [4781]=34, +[4782]=34, [4783]=37, [4784]=37, [4785]=37, [4786]=38, [4787]=50, [4788]=58, [4801]=60, +[4802]=60, [4803]=60, [4804]=60, [4805]=60, [4806]=60, [4807]=60, [4808]=54, [4809]=54, +[4810]=54, [4811]=14, [4812]=14, [4813]=14, [4821]=26, [4822]=60, [4841]=25, [4842]=56, +[4861]=59, [4862]=59, [4863]=59, [4864]=59, [4865]=26, [4866]=60, [4867]=60, [4881]=28, +[4882]=59, [4883]=59, [4901]=59, [4902]=57, [4903]=60, [4904]=29, [4906]=54, [4907]=60, +[4921]=20, [4941]=60, [4961]=40, [4962]=40, [4963]=40, [4964]=40, [4965]=35, [4966]=28, +[4967]=35, [4968]=35, [4969]=35, [4970]=60, [4971]=56, [4972]=56, [4973]=56, [4974]=60, +[4975]=40, [4976]=40, [4981]=59, [4982]=59, [4983]=59, [4984]=54, [4985]=56, [4986]=56, +[4987]=56, [5001]=59, [5002]=59, [5021]=52, [5022]=52, [5023]=52, [5041]=14, [5042]=20, +[5043]=20, [5044]=20, [5045]=20, [5046]=20, [5047]=60, [5048]=52, [5049]=52, [5050]=52, +[5051]=54, [5052]=21, [5054]=56, [5055]=58, [5056]=60, [5057]=60, [5058]=55, [5059]=55, +[5060]=55, [5061]=16, [5062]=27, [5063]=60, [5064]=28, [5065]=58, [5066]=50, [5067]=60, +[5068]=60, [5081]=60, [5082]=56, [5083]=56, [5084]=56, [5085]=56, [5086]=56, [5087]=57, +[5088]=28, [5089]=60, [5090]=50, [5091]=50, [5092]=52, [5093]=50, [5094]=50, [5095]=50, +[5096]=53, [5097]=56, [5098]=56, [5102]=60, [5103]=60, [5121]=59, [5122]=60, [5123]=59, +[5124]=60, [5125]=60, [5126]=60, [5127]=60, [5128]=59, [5141]=55, [5142]=55, [5143]=55, +[5144]=55, [5145]=55, [5146]=55, [5147]=29, [5148]=55, [5149]=55, [5150]=55, [5151]=30, +[5152]=56, [5153]=56, [5154]=56, [5155]=51, [5156]=54, [5157]=52, [5158]=52, [5159]=54, +[5160]=60, [5161]=60, [5162]=60, [5163]=58, [5164]=60, [5165]=55, [5166]=60, [5167]=60, +[5168]=56, [5181]=57, [5201]=60, [5202]=55, [5203]=55, [5204]=57, [5206]=60, [5210]=56, +[5211]=55, [5212]=60, [5213]=60, [5214]=60, [5215]=53, [5216]=53, [5217]=53, [5218]=53, +[5219]=55, [5220]=55, [5221]=55, [5222]=55, [5223]=55, [5224]=55, [5225]=58, [5226]=58, +[5227]=58, [5228]=53, [5229]=53, [5230]=53, [5231]=55, [5232]=55, [5233]=55, [5234]=55, +[5235]=58, [5236]=58, [5237]=58, [5238]=58, [5241]=56, [5242]=58, [5243]=60, [5244]=56, +[5245]=56, [5246]=56, [5247]=57, [5248]=58, [5249]=56, [5250]=56, [5251]=60, [5252]=58, +[5253]=58, [5261]=2, [5262]=60, [5263]=60, [5264]=60, [5265]=60, [5281]=60, [5282]=60, +[5283]=40, [5284]=40, [5301]=40, [5302]=40, [5305]=60, [5306]=60, [5307]=60, [5321]=20, +[5341]=60, [5342]=60, [5343]=60, [5344]=60, [5361]=35, [5381]=38, [5382]=60, [5384]=60, +[5385]=57, [5386]=37, [5401]=55, [5402]=55, [5403]=55, [5404]=55, [5405]=55, [5406]=55, +[5407]=55, [5408]=55, [5421]=25, [5441]=4, [5461]=60, [5462]=60, [5463]=60, [5464]=60, +[5465]=60, [5466]=60, [5481]=5, [5482]=6, [5501]=39, [5502]=60, [5503]=55, [5504]=60, +[5505]=60, [5507]=60, [5508]=55, [5509]=55, [5510]=55, [5511]=60, [5513]=60, [5514]=57, +[5515]=60, [5517]=60, [5518]=60, [5519]=60, [5521]=60, [5522]=60, [5524]=60, [5525]=60, +[5526]=60, [5527]=60, [5528]=60, [5529]=58, [5531]=60, [5533]=55, [5534]=53, [5535]=47, +[5536]=47, [5537]=57, [5538]=57, [5541]=6, [5542]=56, [5543]=56, [5544]=56, [5545]=9, +[5561]=34, [5581]=38, [5582]=58, [5601]=55, [5621]=4, [5622]=4, [5623]=4, [5624]=4, +[5625]=4, [5626]=4, [5627]=10, [5628]=10, [5629]=10, [5630]=10, [5631]=10, [5632]=10, +[5633]=10, [5634]=10, [5635]=10, [5636]=10, [5637]=10, [5638]=10, [5639]=10, [5640]=10, +[5641]=20, [5642]=20, [5643]=20, [5644]=20, [5645]=20, [5646]=20, [5647]=20, [5648]=4, +[5649]=4, [5650]=4, [5651]=4, [5652]=10, [5654]=10, [5655]=10, [5656]=10, [5657]=10, +[5658]=10, [5659]=10, [5660]=10, [5661]=10, [5662]=10, [5663]=10, [5672]=20, [5673]=20, +[5674]=20, [5675]=20, [5676]=20, [5677]=20, [5678]=20, [5679]=20, [5680]=20, [5713]=15, +[5721]=60, [5722]=16, [5723]=15, [5724]=16, [5725]=16, [5726]=12, [5727]=12, [5728]=16, +[5729]=15, [5730]=16, [5741]=33, [5742]=56, [5761]=16, [5762]=31, [5763]=31, [5781]=57, +[5801]=57, [5802]=57, [5803]=60, [5804]=60, [5821]=35, [5845]=58, [5846]=58, [5848]=60, +[5861]=60, [5862]=60, [5863]=49, [5881]=28, [5882]=55, [5883]=55, [5884]=55, [5885]=55, +[5886]=55, [5887]=55, [5888]=55, [5889]=55, [5890]=55, [5891]=55, [5892]=55, [5893]=55, +[5901]=55, [5902]=55, [5903]=55, [5904]=55, [5921]=10, [5922]=10, [5923]=10, [5924]=10, +[5925]=10, [5926]=10, [5927]=10, [5928]=10, [5929]=10, [5930]=10, [5931]=10, [5932]=10, +[5941]=60, [5942]=60, [5943]=38, [5944]=60, [5961]=56, [5981]=60, [6000]=50, [6001]=10, +[6002]=10, [6004]=56, [6021]=55, [6022]=58, [6023]=57, [6024]=60, [6025]=58, [6026]=58, +[6027]=38, [6028]=52, [6029]=52, [6030]=52, [6031]=55, [6032]=55, [6041]=58, [6042]=58, +[6061]=10, [6062]=10, [6063]=10, [6064]=10, [6065]=10, [6066]=10, [6067]=10, [6068]=10, +[6069]=10, [6070]=10, [6071]=10, [6072]=10, [6073]=10, [6074]=10, [6075]=10, [6076]=10, +[6081]=10, [6082]=10, [6083]=10, [6084]=10, [6085]=10, [6086]=10, [6087]=10, [6088]=10, +[6089]=10, [6101]=10, [6102]=10, [6103]=10, [6121]=14, [6122]=14, [6123]=14, [6124]=14, +[6125]=14, [6126]=14, [6127]=14, [6128]=14, [6129]=14, [6130]=14, [6131]=48, [6132]=39, +[6133]=60, [6134]=39, [6135]=60, [6136]=60, [6141]=39, [6142]=35, [6143]=36, [6144]=60, +[6145]=60, [6146]=60, [6147]=60, [6148]=60, [6161]=36, [6162]=51, [6163]=60, [6164]=55, +[6165]=58, [6181]=10, [6182]=60, [6183]=60, [6184]=60, [6185]=60, [6186]=60, [6187]=60, +[6221]=55, [6241]=56, [6261]=10, [6281]=10, [6282]=26, [6283]=26, [6284]=21, [6285]=10, +[6301]=23, [6321]=10, [6322]=10, [6323]=10, [6324]=10, [6341]=10, [6342]=10, [6343]=10, +[6344]=10, [6361]=10, [6362]=10, [6363]=10, [6364]=10, [6365]=10, [6381]=25, [6382]=20, +[6383]=20, [6384]=10, [6385]=10, [6386]=10, [6387]=10, [6388]=10, [6389]=55, [6390]=55, +[6391]=10, [6392]=10, [6393]=25, [6394]=4, [6395]=5, [6401]=18, [6402]=60, [6403]=60, +[6421]=18, [6441]=26, [6442]=19, [6461]=19, [6462]=24, [6481]=20, [6482]=24, [6501]=60, +[6502]=60, [6503]=24, [6504]=30, [6521]=36, [6522]=36, [6523]=18, [6541]=19, [6542]=19, +[6543]=19, [6544]=24, [6545]=19, [6546]=25, [6547]=21, [6548]=18, [6561]=27, [6562]=22, +[6563]=22, [6564]=22, [6565]=26, [6566]=60, [6567]=60, [6568]=60, [6569]=60, [6570]=60, +[6571]=27, [6581]=27, [6582]=60, [6583]=60, [6584]=60, [6585]=60, [6601]=60, [6602]=60, +[6603]=56, [6604]=59, [6605]=54, [6606]=60, [6607]=45, [6608]=45, [6609]=45, [6610]=45, +[6611]=45, [6612]=45, [6621]=26, [6622]=45, [6623]=45, [6624]=45, [6625]=45, [6626]=35, +[6627]=30, [6628]=30, [6629]=18, [6641]=23, [6642]=60, [6643]=60, [6644]=60, [6645]=60, +[6646]=60, [6661]=12, [6662]=12, [6681]=24, [6701]=60, [6721]=10, [6722]=10, [6741]=60, +[6761]=55, [6762]=55, [6781]=60, [6801]=60, [6804]=56, [6805]=57, [6821]=60, [6822]=60, +[6823]=60, [6824]=60, [6825]=60, [6826]=60, [6827]=60, [6881]=60, [6901]=60, [6921]=27, +[6922]=30, [6941]=60, [6942]=60, [6943]=60, [6961]=60, [6962]=60, [6963]=60, [6964]=60, +[6981]=26, [6982]=55, [6983]=60, [6984]=60, [6985]=55, [7001]=60, [7002]=60, [7003]=48, +[7021]=60, [7022]=60, [7023]=60, [7024]=60, [7025]=60, [7026]=60, [7027]=60, [7028]=47, +[7029]=47, [7041]=47, [7042]=60, [7043]=60, [7044]=49, [7045]=60, [7046]=49, [7061]=60, +[7062]=60, [7063]=60, [7064]=51, [7065]=51, [7066]=51, [7067]=48, [7068]=42, [7070]=42, +[7081]=60, [7082]=60, [7101]=60, [7102]=60, [7121]=60, [7122]=60, [7123]=60, [7124]=60, +[7141]=60, [7142]=60, [7161]=60, [7162]=60, [7163]=60, [7164]=60, [7165]=60, [7166]=60, +[7167]=60, [7168]=60, [7169]=60, [7170]=60, [7171]=60, [7172]=60, [7181]=60, [7201]=54, +[7202]=60, [7223]=60, [7224]=60, [7241]=60, [7261]=60, [7281]=60, [7282]=60, [7301]=60, +[7302]=60, [7321]=31, [7341]=60, [7342]=60, [7361]=60, [7362]=60, [7363]=60, [7364]=60, +[7365]=60, [7366]=60, [7367]=60, [7368]=60, [7381]=60, [7382]=60, [7383]=11, [7385]=60, +[7386]=60, [7401]=60, [7402]=60, [7421]=60, [7422]=60, [7423]=60, [7424]=60, [7425]=60, +[7426]=60, [7427]=60, [7428]=60, [7429]=60, [7441]=58, [7461]=60, [7462]=60, [7463]=60, +[7478]=60, [7479]=60, [7480]=60, [7481]=60, [7482]=60, [7483]=60, [7484]=60, [7485]=60, +[7486]=60, [7487]=60, [7488]=57, [7489]=57, [7490]=60, [7491]=60, [7492]=57, [7493]=60, +[7494]=57, [7495]=60, [7496]=60, [7497]=60, [7498]=60, [7499]=60, [7500]=60, [7501]=60, +[7502]=60, [7503]=60, [7504]=60, [7505]=60, [7506]=60, [7507]=60, [7508]=60, [7509]=60, +[7521]=60, [7541]=40, [7562]=58, [7563]=58, [7564]=58, [7581]=60, [7582]=60, [7583]=60, +[7601]=50, [7602]=55, [7603]=55, [7604]=60, [7621]=60, [7622]=60, [7623]=58, [7624]=58, +[7625]=60, [7626]=60, [7627]=60, [7628]=60, [7629]=60, [7630]=60, [7631]=60, [7632]=60, +[7633]=60, [7634]=60, [7635]=60, [7636]=60, [7637]=60, [7638]=60, [7639]=60, [7640]=60, +[7641]=60, [7642]=60, [7643]=60, [7644]=60, [7645]=60, [7646]=60, [7647]=60, [7648]=60, +[7649]=60, [7650]=60, [7651]=60, [7652]=60, [7653]=60, [7654]=60, [7655]=60, [7656]=60, +[7657]=60, [7658]=60, [7659]=60, [7666]=60, [7667]=60, [7668]=60, [7701]=50, [7703]=60, +[7721]=48, [7722]=50, [7723]=49, [7724]=49, [7725]=55, [7726]=55, [7727]=49, [7728]=48, +[7729]=48, [7730]=45, [7731]=47, [7732]=48, [7733]=48, [7734]=48, [7735]=48, [7736]=60, +[7737]=60, [7738]=48, [7761]=60, [7781]=60, [7782]=60, [7783]=60, [7784]=60, [7785]=60, +[7786]=60, [7787]=60, [7788]=29, [7789]=29, [7791]=60, [7792]=60, [7793]=60, [7794]=60, +[7795]=60, [7796]=60, [7798]=60, [7799]=60, [7800]=60, [7801]=60, [7802]=60, [7803]=60, +[7804]=60, [7805]=60, [7806]=60, [7807]=60, [7808]=60, [7809]=60, [7810]=55, [7811]=60, +[7812]=60, [7813]=60, [7814]=60, [7815]=50, [7816]=48, [7817]=60, [7818]=60, [7819]=60, +[7820]=60, [7821]=60, [7822]=60, [7823]=60, [7824]=60, [7825]=60, [7826]=60, [7827]=60, +[7828]=48, [7829]=48, [7830]=48, [7831]=60, [7832]=60, [7833]=60, [7834]=60, [7835]=60, +[7836]=60, [7837]=60, [7838]=55, [7839]=48, [7840]=49, [7841]=48, [7842]=48, [7843]=50, +[7844]=48, [7845]=51, [7846]=51, [7847]=51, [7848]=60, [7849]=50, [7850]=50, [7861]=51, +[7862]=51, [7863]=34, [7864]=44, [7865]=60, [7866]=34, [7867]=44, [7868]=60, [7871]=39, +[7872]=49, [7873]=59, [7874]=39, [7875]=49, [7876]=59, [7877]=60, [7881]=60, [7882]=60, +[7883]=60, [7884]=60, [7885]=60, [7886]=55, [7887]=35, [7888]=45, [7889]=60, [7890]=60, +[7891]=60, [7892]=60, [7893]=60, [7894]=60, [7895]=60, [7896]=60, [7897]=60, [7898]=60, +[7899]=60, [7900]=60, [7901]=60, [7902]=60, [7903]=60, [7905]=60, [7907]=55, [7921]=25, +[7922]=55, [7923]=45, [7924]=35, [7925]=25, [7926]=60, [7927]=55, [7928]=55, [7929]=55, +[7930]=60, [7931]=60, [7932]=60, [7933]=60, [7934]=60, [7935]=60, [7936]=60, [7937]=60, +[7938]=60, [7939]=60, [7940]=60, [7941]=60, [7942]=60, [7943]=60, [7944]=60, [7945]=60, +[7946]=1, [7981]=60, [8001]=3, [8041]=60, [8042]=60, [8043]=60, [8044]=60, [8045]=60, +[8046]=60, [8047]=60, [8048]=60, [8049]=60, [8050]=60, [8051]=60, [8052]=60, [8053]=61, +[8054]=61, [8055]=61, [8056]=60, [8057]=60, [8058]=60, [8059]=60, [8060]=60, [8061]=60, +[8062]=60, [8063]=60, [8064]=60, [8065]=60, [8066]=60, [8067]=60, [8068]=60, [8069]=60, +[8070]=60, [8071]=60, [8072]=60, [8073]=60, [8074]=60, [8075]=60, [8076]=60, [8077]=60, +[8078]=60, [8079]=60, [8080]=59, [8081]=55, [8101]=60, [8102]=60, [8103]=60, [8104]=60, +[8105]=55, [8106]=60, [8107]=60, [8108]=60, [8109]=60, [8110]=60, [8111]=60, [8112]=60, +[8113]=60, [8114]=60, [8115]=60, [8116]=60, [8117]=60, [8118]=60, [8119]=60, [8120]=55, +[8121]=60, [8122]=60, [8123]=59, [8124]=55, [8141]=60, [8142]=60, [8143]=60, [8144]=60, +[8145]=60, [8146]=60, [8147]=60, [8148]=60, [8149]=60, [8150]=60, [8151]=52, [8153]=52, +[8154]=49, [8155]=29, [8156]=39, [8157]=45, [8158]=25, [8159]=35, [8160]=49, [8161]=39, +[8162]=29, [8163]=45, [8164]=35, [8165]=25, [8166]=45, [8167]=35, [8168]=25, [8169]=45, +[8170]=35, [8171]=25, [8181]=58, [8182]=58, [8183]=60, [8184]=60, [8185]=60, [8186]=60, +[8187]=60, [8188]=60, [8189]=60, [8190]=60, [8191]=60, [8192]=60, [8193]=60, [8194]=60, +[8195]=60, [8196]=60, [8201]=60, [8221]=60, [8222]=60, [8223]=60, [8224]=60, [8225]=60, +[8227]=60, [8228]=60, [8229]=60, [8231]=52, [8232]=52, [8233]=52, [8234]=52, [8235]=52, +[8236]=52, [8238]=60, [8239]=60, [8240]=60, [8241]=60, [8242]=60, [8243]=60, [8246]=60, +[8249]=60, [8250]=52, [8251]=52, [8252]=52, [8253]=52, [8254]=52, [8255]=52, [8256]=52, +[8257]=52, [8258]=60, [8260]=34, [8261]=44, [8262]=60, [8263]=34, [8264]=44, [8265]=60, +[8266]=39, [8267]=60, [8268]=39, [8269]=60, [8271]=60, [8272]=60, [8273]=47, [8275]=55, +[8276]=55, [8277]=55, [8278]=57, [8279]=60, [8280]=55, [8281]=57, [8282]=58, [8283]=59, +[8284]=58, [8285]=59, [8286]=60, [8287]=60, [8288]=60, [8289]=15, [8290]=19, [8291]=60, +[8292]=60, [8293]=60, [8294]=60, [8295]=19, [8296]=15, [8297]=60, [8298]=60, [8299]=60, +[8300]=60, [8301]=60, [8302]=60, [8303]=60, [8304]=60, [8305]=60, [8306]=60, [8307]=57, +[8308]=60, [8309]=60, [8310]=60, [8311]=60, [8312]=60, [8313]=57, [8314]=60, [8315]=60, +[8316]=60, [8317]=57, [8318]=60, [8319]=60, [8320]=60, [8321]=60, [8322]=60, [8323]=59, +[8324]=59, [8331]=60, [8332]=60, [8333]=60, [8341]=60, [8342]=60, [8343]=60, [8348]=60, +[8349]=60, [8351]=60, [8352]=60, [8353]=60, [8354]=60, [8355]=60, [8356]=60, [8357]=60, +[8358]=60, [8359]=60, [8360]=60, [8361]=60, [8362]=60, [8363]=60, [8364]=60, [8365]=45, +[8366]=45, [8367]=60, [8368]=19, [8369]=60, [8370]=29, [8371]=60, [8372]=19, [8373]=60, +[8374]=29, [8375]=60, [8376]=60, [8377]=60, [8378]=60, [8379]=60, [8380]=60, [8381]=60, +[8382]=60, [8383]=60, [8384]=29, [8385]=60, [8386]=19, [8387]=60, [8388]=60, [8389]=19, +[8390]=29, [8391]=39, [8392]=49, [8393]=39, [8394]=49, [8395]=59, [8396]=60, [8397]=59, +[8398]=60, [8399]=29, [8400]=39, [8401]=49, [8402]=59, [8403]=60, [8404]=29, [8405]=39, +[8406]=49, [8407]=59, [8408]=60, [8409]=60, [8410]=50, [8411]=50, [8412]=52, [8413]=52, +[8414]=52, [8415]=50, [8416]=52, [8417]=50, [8418]=52, [8419]=52, [8420]=52, [8421]=52, +[8422]=52, [8423]=52, [8424]=53, [8425]=52, [8426]=29, [8427]=39, [8428]=49, [8429]=59, +[8430]=60, [8431]=29, [8432]=39, [8433]=49, [8434]=59, [8435]=60, [8436]=39, [8437]=49, +[8438]=59, [8439]=60, [8440]=39, [8441]=49, [8442]=59, [8443]=60, [8446]=60, [8447]=60, +[8460]=48, [8461]=55, [8462]=55, [8464]=58, [8465]=55, [8466]=55, [8467]=55, [8469]=56, +[8470]=55, [8471]=56, [8481]=60, [8484]=60, [8485]=60, [8492]=60, [8493]=60, [8494]=60, +[8495]=60, [8496]=60, [8497]=60, [8498]=60, [8499]=60, [8500]=60, [8501]=60, [8502]=60, +[8503]=60, [8504]=60, [8505]=60, [8506]=60, [8507]=60, [8508]=60, [8509]=60, [8510]=60, +[8511]=60, [8512]=60, [8513]=60, [8514]=60, [8515]=60, [8516]=60, [8517]=60, [8518]=60, +[8519]=60, [8520]=60, [8521]=60, [8522]=60, [8523]=60, [8524]=60, [8525]=60, [8526]=60, +[8527]=60, [8528]=60, [8529]=60, [8532]=60, [8533]=60, [8534]=60, [8535]=60, [8536]=60, +[8537]=60, [8538]=60, [8539]=60, [8540]=60, [8541]=60, [8542]=60, [8543]=60, [8544]=60, +[8545]=60, [8546]=60, [8548]=60, [8549]=60, [8550]=60, [8551]=47, [8552]=50, [8553]=50, +[8554]=50, [8555]=60, [8556]=60, [8557]=60, [8558]=60, [8559]=60, [8560]=60, [8561]=60, +[8562]=60, [8572]=60, [8573]=60, [8574]=60, [8575]=60, [8576]=60, [8577]=60, [8578]=60, +[8579]=60, [8580]=60, [8581]=60, [8582]=60, [8583]=60, [8584]=60, [8585]=60, [8586]=60, +[8587]=60, [8588]=60, [8589]=60, [8590]=60, [8591]=60, [8592]=60, [8593]=60, [8594]=60, +[8595]=60, [8596]=60, [8597]=60, [8598]=60, [8599]=60, [8600]=60, [8601]=60, [8602]=60, +[8603]=60, [8604]=60, [8605]=60, [8606]=60, [8607]=60, [8608]=60, [8609]=60, [8610]=60, +[8611]=60, [8612]=60, [8613]=60, [8614]=60, [8615]=60, [8616]=60, [8619]=60, [8620]=60, +[8621]=60, [8622]=60, [8623]=60, [8624]=60, [8625]=60, [8626]=60, [8627]=60, [8628]=60, +[8629]=60, [8630]=60, [8631]=60, [8632]=60, [8633]=60, [8634]=60, [8635]=60, [8636]=60, +[8637]=60, [8638]=60, [8639]=60, [8640]=60, [8641]=60, [8642]=60, [8643]=60, [8644]=60, +[8645]=60, [8646]=60, [8647]=60, [8648]=60, [8649]=60, [8650]=60, [8651]=60, [8652]=60, +[8653]=60, [8654]=60, [8655]=60, [8656]=60, [8657]=60, [8658]=60, [8659]=60, [8660]=60, +[8661]=60, [8662]=60, [8663]=60, [8664]=60, [8665]=60, [8666]=60, [8667]=60, [8668]=60, +[8669]=60, [8670]=60, [8671]=60, [8672]=60, [8673]=60, [8674]=60, [8675]=60, [8676]=60, +[8677]=60, [8678]=60, [8679]=60, [8680]=60, [8681]=60, [8682]=60, [8683]=60, [8684]=60, +[8685]=60, [8686]=60, [8687]=60, [8688]=60, [8689]=60, [8690]=60, [8691]=60, [8692]=60, +[8693]=60, [8694]=60, [8695]=60, [8696]=60, [8697]=60, [8698]=60, [8699]=60, [8700]=60, +[8701]=60, [8702]=60, [8703]=60, [8704]=60, [8705]=60, [8706]=60, [8707]=60, [8708]=60, +[8709]=60, [8710]=60, [8711]=60, [8712]=60, [8713]=60, [8714]=60, [8715]=60, [8716]=60, +[8717]=60, [8718]=60, [8719]=60, [8720]=60, [8721]=60, [8722]=60, [8723]=60, [8724]=60, +[8725]=60, [8726]=60, [8727]=60, [8728]=60, [8729]=60, [8730]=60, [8731]=60, [8732]=60, +[8733]=60, [8734]=60, [8735]=60, [8736]=60, [8737]=60, [8738]=60, [8739]=60, [8740]=60, +[8741]=60, [8742]=60, [8743]=60, [8744]=1, [8745]=60, [8746]=60, [8747]=60, [8748]=60, +[8749]=60, [8750]=60, [8751]=60, [8752]=60, [8753]=60, [8754]=60, [8755]=60, [8756]=60, +[8757]=60, [8758]=60, [8759]=60, [8760]=60, [8761]=60, [8762]=60, [8763]=60, [8764]=60, +[8765]=60, [8766]=60, [8767]=1, [8768]=20, [8769]=40, [8770]=60, [8771]=60, [8772]=60, +[8773]=60, [8774]=60, [8775]=60, [8776]=60, [8777]=60, [8778]=60, [8779]=60, [8780]=60, +[8781]=60, [8782]=60, [8783]=60, [8784]=60, [8785]=60, [8786]=60, [8787]=60, [8788]=1, +[8789]=60, [8790]=60, [8791]=60, [8792]=60, [8793]=60, [8794]=60, [8795]=60, [8796]=60, +[8797]=60, [8798]=60, [8799]=60, [8800]=60, [8801]=60, [8802]=60, [8803]=10, [8804]=60, +[8805]=60, [8806]=60, [8807]=60, [8808]=60, [8809]=60, [8810]=60, [8811]=60, [8812]=60, +[8813]=60, [8814]=60, [8815]=60, [8816]=60, [8817]=60, [8818]=60, [8819]=60, [8820]=60, +[8821]=60, [8822]=60, [8823]=60, [8824]=60, [8825]=60, [8826]=60, [8827]=1, [8828]=1, +[8829]=60, [8830]=60, [8831]=60, [8832]=60, [8833]=60, [8834]=60, [8835]=60, [8836]=60, +[8837]=60, [8838]=60, [8839]=60, [8840]=60, [8841]=60, [8842]=60, [8843]=60, [8844]=60, +[8845]=60, [8846]=19, [8847]=29, [8848]=39, [8849]=49, [8850]=60, [8851]=19, [8852]=29, +[8853]=39, [8854]=49, [8855]=60, [8857]=60, [8858]=60, [8859]=60, [8860]=60, [8861]=60, +[8862]=60, [8863]=60, [8864]=60, [8865]=60, [8866]=60, [8867]=60, [8868]=60, [8870]=60, +[8871]=60, [8872]=60, [8873]=60, [8874]=60, [8875]=60, [8876]=60, [8877]=60, [8878]=60, +[8879]=60, [8880]=60, [8881]=60, [8882]=60, [8883]=60, [8893]=47, [8897]=60, [8898]=60, +[8899]=60, [8900]=60, [8901]=60, [8902]=60, [8903]=60, [8904]=60, [8905]=60, [8906]=60, +[8907]=60, [8908]=60, [8909]=60, [8910]=60, [8911]=60, [8912]=60, [8913]=60, [8914]=60, +[8915]=60, [8916]=60, [8917]=60, [8918]=60, [8919]=60, [8920]=60, [8921]=60, [8922]=60, +[8923]=60, [8924]=60, [8925]=60, [8926]=60, [8927]=60, [8928]=60, [8929]=60, [8930]=60, +[8931]=60, [8932]=60, [8933]=60, [8934]=60, [8935]=60, [8936]=60, [8937]=60, [8938]=60, +[8939]=60, [8940]=60, [8941]=60, [8942]=60, [8943]=60, [8944]=60, [8945]=60, [8946]=60, +[8947]=60, [8948]=60, [8949]=60, [8950]=60, [8951]=60, [8952]=60, [8953]=60, [8954]=60, +[8955]=60, [8956]=60, [8957]=60, [8958]=60, [8959]=60, [8960]=60, [8961]=60, [8962]=60, +[8963]=60, [8964]=60, [8965]=60, [8966]=60, [8967]=60, [8968]=60, [8969]=60, [8970]=60, +[8977]=60, [8978]=60, [8979]=60, [8980]=60, [8981]=60, [8982]=60, [8983]=60, [8984]=60, +[8985]=60, [8986]=60, [8987]=60, [8988]=60, [8989]=60, [8990]=60, [8991]=60, [8992]=60, +[8993]=60, [8994]=60, [8995]=60, [8996]=60, [8997]=60, [8998]=60, [8999]=60, [9000]=60, +[9001]=60, [9002]=60, [9003]=60, [9004]=60, [9005]=60, [9006]=60, [9007]=60, [9008]=60, +[9009]=60, [9010]=60, [9011]=60, [9012]=60, [9013]=60, [9014]=60, [9015]=60, [9016]=60, +[9017]=60, [9018]=60, [9019]=60, [9020]=60, [9021]=60, [9022]=60, [9023]=60, [9024]=60, +[9025]=60, [9026]=60, [9027]=60, [9028]=60, [9029]=60, [9032]=60, [9033]=60, [9034]=60, +[9036]=60, [9037]=60, [9038]=60, [9039]=60, [9040]=60, [9041]=60, [9042]=60, [9043]=60, +[9044]=60, [9045]=60, [9046]=60, [9047]=60, [9048]=60, [9049]=60, [9050]=60, [9051]=52, +[9052]=52, [9053]=52, [9054]=60, [9055]=60, [9056]=60, [9057]=60, [9058]=60, [9059]=60, +[9060]=60, [9061]=60, [9063]=52, [9068]=60, [9069]=60, [9070]=60, [9071]=60, [9072]=60, +[9073]=60, [9074]=60, [9075]=60, [9077]=60, [9078]=60, [9079]=60, [9080]=60, [9081]=60, +[9082]=60, [9083]=60, [9084]=60, [9085]=60, [9086]=60, [9087]=60, [9088]=60, [9089]=60, +[9090]=60, [9091]=60, [9092]=60, [9093]=60, [9094]=60, [9095]=60, [9096]=60, [9097]=60, +[9098]=60, [9099]=60, [9100]=60, [9101]=60, [9102]=60, [9103]=60, [9104]=60, [9105]=60, +[9106]=60, [9107]=60, [9108]=60, [9109]=60, [9110]=60, [9111]=60, [9112]=60, [9113]=60, +[9114]=60, [9115]=60, [9116]=60, [9117]=60, [9118]=60, [9120]=60, [9121]=60, [9122]=60, +[9123]=60, [9124]=60, [9125]=60, [9126]=60, [9127]=60, [9128]=60, [9129]=60, [9131]=60, +[9132]=60, [9136]=60, [9137]=60, [9141]=60, [9142]=60, [9153]=60, [9154]=60, [9165]=60, +[9178]=60, [9179]=60, [9181]=60, [9182]=60, [9183]=60, [9184]=60, [9185]=60, [9186]=60, +[9187]=60, [9188]=60, [9190]=60, [9191]=60, [9194]=60, [9195]=60, [9196]=60, [9197]=60, +[9198]=60, [9200]=60, [9201]=60, [9202]=60, [9203]=60, [9204]=60, [9205]=60, [9206]=60, +[9208]=60, [9209]=60, [9210]=60, [9211]=60, [9213]=60, [9221]=60, [9222]=60, [9223]=60, +[9224]=60, [9225]=60, [9226]=60, [9227]=60, [9228]=60, [9229]=60, [9230]=60, [9232]=60, +[9233]=60, [9234]=60, [9235]=60, [9236]=60, [9237]=60, [9238]=60, [9239]=60, [9240]=60, +[9241]=60, [9242]=60, [9243]=60, [9244]=60, [9245]=60, [9246]=60, [9247]=60, [9248]=60, +[9250]=60, [9251]=60, [9257]=60, [9259]=60, [9260]=6, [9261]=10, [9262]=10, [9263]=10, +[9264]=10, [9265]=8, [9266]=60, [9267]=60, [9268]=60, [9269]=60, [9270]=60, [9271]=60, +[9272]=55, [9292]=60, [9295]=60, [9299]=60, [9300]=60, [9301]=60, [9302]=60, [9304]=60, +[9310]=60, [9317]=60, [9318]=60, [9319]=60, [9320]=60, [9321]=60, [9322]=60, [9323]=60, +[9324]=1, [9325]=1, [9326]=1, [9330]=1, [9331]=1, [9332]=1, [9338]=60, [9339]=1, +[9341]=60, [9362]=60, [9364]=60, [9365]=1, [9367]=60, [9368]=60, [9378]=60, [9386]=60, +[9388]=25, [9389]=25, [9415]=60, [9416]=60, [9419]=60, [9422]=60, [9664]=60, [9665]=60, +[41747]=6, [41895]=6, [50003]=6, [60050]=5, +} diff --git a/CodexZones.lua b/CodexZones.lua new file mode 100644 index 0000000..86c5e01 --- /dev/null +++ b/CodexZones.lua @@ -0,0 +1,237 @@ +-- Freewood-OctoWoW-Codex: Gebiete mit Stufenspanne, berechnet aus den gesammelten Kreaturen. +-- +-- Jede Kreatur zaehlt mit ihren Sichtungen (Zellen) in den Karten, in denen sie gesehen wurde, Instanzen mit ihrer +-- Instanz-ID (negative Zone). Pro Gebiet: +-- lo, hi typische Spanne: alle Stufen, die mindestens 5 % des Gewichts normaler Kreaturen haben +-- avg gewichteter Durchschnitt innerhalb der Spanne +-- out Ausreisser: normale Kreaturen ausserhalb der Spanne +-- special Elite / Selten / Boss usw. (fliessen nicht in die Spanne ein) +-- few zu wenig Daten (weniger als MIN_UNITS normale Kreaturen): Spanne nur grob +-- manual Spanne von Berechtigten von Hand gesetzt (db.ied, siehe Codex.ZoneEdID) +-- Wird mit den geteilten Kreaturdaten berechnet, daher sehen alle dasselbe. + +local ADDON, NS = ... + +local FW = _G.Freewood +if not FW or not FW:RequireAPI(1, ADDON) then + return +end + +local Codex = NS and NS.Codex +if not Codex then + return +end + +local Zones = {} +Codex.Zones = Zones + +local GRID = Codex.GRID or 200 +local MIN_UNITS = 3 -- weniger normale Kreaturen: "zu wenig Daten" +local LEVEL_SHARE = 0.05 -- Mindestanteil einer Stufe am Gewicht, damit sie zur typischen Spanne gehoert +local WEIGHT_CAP = 30 -- eine haeufig gesehene Kreatur zaehlt hoechstens so viel +local MAP_SHARE = 0.10 -- Karte zaehlt fuer eine Kreatur erst ab diesem Anteil ihrer Sichtungen +local CACHE_SECS = 5 + +local cache, cacheAt, dirty = nil, 0, true + +function Zones.Invalidate() + dirty = true +end + +FW:On("CODEX_CHANGED", function() dirty = true end, Zones) + +local function Now() + return GetTime and GetTime() or 0 +end + +-- Gewichte einer Kreatur je Gebiet: { [zoneKey] = Gewicht } +local function Spread(u) + local out = {} + if type(u.ez) == "table" and #u.ez > 0 then + local each = 1 / #u.ez + local total = 0 + for _, n in pairs(u.cells or {}) do total = total + n end + total = math.max(1, total) + for _, m in ipairs(u.ez) do out[m] = total * each end + return out + end + if type(u.iz) == "table" and next(u.iz) and u.dg ~= false then + for id, n in pairs(u.iz) do out[-id] = n end + return out + end + local per, total = {}, 0 + for key, n in pairs(u.cells or {}) do + local m = math.floor(key / (GRID * GRID)) + per[m] = (per[m] or 0) + n + total = total + n + end + for m, n in pairs(per) do + if total > 0 and n / total >= MAP_SHARE then out[m] = n end + end + return out +end + +local function ManualRange(ied, key) + local rec = ied and ied[Codex.ZoneEdID(key)] + if not rec then return nil end + local l1 = rec.l1 or (rec.ed and rec.ed.l1) + local l2 = rec.l2 or (rec.ed and rec.ed.l2) + if l1 then return l1, l2 or l1 end + return nil +end + +local function Compute() + local V = Codex.V() + local zones = {} + local function Zone(key) + local z = zones[key] + if not z then + z = { id = key, normal = {}, special = {}, out = {} } + zones[key] = z + end + return z + end + for id, u in pairs(V.units or {}) do + if u.l1 and u.bad ~= true then + local special = u.c ~= nil and u.c ~= false + local l2 = u.l2 or u.l1 + for key, n in pairs(Spread(u)) do + local w = math.max(1, math.min(WEIGHT_CAP, n)) + local entry = { id = id, l1 = u.l1, l2 = l2, w = w, c = special and u.c or nil, mid = (u.l1 + l2) / 2 } + local z = Zone(key) + if special then + z.special[#z.special + 1] = entry + else + z.normal[#z.normal + 1] = entry + end + end + end + end + for key, z in pairs(zones) do + local byLevel, total = {}, 0 + local minL, maxL + for _, e in ipairs(z.normal) do + -- eine Kreatur mit Spanne (Stufe 3-4) zaehlt je zur Haelfte fuer beide Stufen + local a, b = e.l1, e.l2 + local half = e.w / ((b > a) and 2 or 1) + byLevel[a] = (byLevel[a] or 0) + half + if b > a then byLevel[b] = (byLevel[b] or 0) + half end + total = total + e.w + minL = minL and math.min(minL, a) or a + maxL = maxL and math.max(maxL, b) or b + end + z.units = #z.normal + z.few = z.units < MIN_UNITS + local lo, hi + if total > 0 then + for lv, w in pairs(byLevel) do + if w / total >= LEVEL_SHARE then + lo = lo and math.min(lo, lv) or lv + hi = hi and math.max(hi, lv) or lv + end + end + lo, hi = lo or minL, hi or maxL + elseif #z.special > 0 then + for _, e in ipairs(z.special) do + minL = minL and math.min(minL, e.l1) or e.l1 + maxL = maxL and math.max(maxL, e.l2) or e.l2 + end + lo, hi = minL, maxL + z.few = true + end + local ml, mh = ManualRange(V.ied, key) + if ml then + lo, hi = ml, mh + z.manual = true + z.few = false + end + z.lo, z.hi = lo, hi + local sum, wsum = 0, 0 + for _, e in ipairs(z.normal) do + if lo and e.mid >= lo and e.mid <= hi then + sum, wsum = sum + e.mid * e.w, wsum + e.w + end + end + z.avg = wsum > 0 and sum / wsum or ((lo and hi) and (lo + hi) / 2 or nil) + for _, e in ipairs(z.normal) do + if lo and (e.l2 < lo or e.l1 > hi) then z.out[#z.out + 1] = e end + end + z.dungeon = key < 0 + z.minL, z.maxL = minL, maxL + end + -- von Hand gesetzte Spannen auch fuer Gebiete ohne Kreaturdaten + for edID, rec in pairs(V.ied or {}) do + local key = Codex.ZoneFromEdID(edID) + if key and not zones[key] then + local l1, l2 = ManualRange(V.ied, key) + if l1 then + zones[key] = { id = key, normal = {}, special = {}, out = {}, units = 0, lo = l1, hi = l2, avg = (l1 + l2) / 2, + manual = true, dungeon = key < 0 } + end + end + end + return zones +end + +-- Alle Gebiete: { [zoneKey] = Daten }. Kurz zwischengespeichert. +function Zones.All() + local t = Now() + if not cache or (dirty and t - cacheAt >= CACHE_SECS) or t < cacheAt then + local ok, res = pcall(Compute) + cache = ok and res or cache or {} + cacheAt, dirty = t, false + end + return cache +end + +-- Sofort neu berechnen (zum Beispiel nach einer Aenderung im Fenster oder in Tests) +function Zones.Refresh() + dirty = true + cacheAt = -1e9 + return Zones.All() +end + +function Zones.Get(key) + return Zones.All()[key] +end + +-- Eine Kreatur gehoert zu einem Gebiet: Liste fuer die Detailansicht, nach Stufe sortiert +function Zones.Members(key) + local z = Zones.Get(key) + if not z then return {} end + local list = {} + local outSet = {} + for _, e in ipairs(z.out) do outSet[e] = true end + for _, e in ipairs(z.normal) do + list[#list + 1] = { id = e.id, l1 = e.l1, l2 = e.l2, w = e.w, out = outSet[e] or nil } + end + for _, e in ipairs(z.special) do + list[#list + 1] = { id = e.id, l1 = e.l1, l2 = e.l2, w = e.w, c = e.c } + end + table.sort(list, function(a, b) + if a.l1 ~= b.l1 then return a.l1 < b.l1 end + if a.l2 ~= b.l2 then return a.l2 < b.l2 end + return a.id < b.id + end) + return list +end + +-- Passt die Stufe zum Gebiet? Rueckgabe: "in" (Stufe liegt in der Spanne), "below" (Spanne ist niedriger), +-- "above" (Spanne ist hoeher), nil (keine Daten). tol = Toleranz in Stufen. +function Zones.Compare(key, level, tol) + local z = Zones.Get(key) + if not z or not z.lo or z.few and not z.manual then return nil end + tol = tol or 0 + if level < z.lo - tol then return "above" end + if level > z.hi + tol then return "below" end + return "in" +end + +-- Gebiete, deren typische Spanne die Stufe enthaelt (mit Toleranz), mit genug Daten +function Zones.ForLevel(level, tol) + local out = {} + for key, z in pairs(Zones.All()) do + if Zones.Compare(key, level, tol) == "in" then out[#out + 1] = z end + end + return out +end diff --git a/Freewood-OctoWoW-Codex.toc b/Freewood-OctoWoW-Codex.toc new file mode 100644 index 0000000..5e1226a --- /dev/null +++ b/Freewood-OctoWoW-Codex.toc @@ -0,0 +1,41 @@ +## Interface: 11200 +## Title: Freewood |cffc89b3cCodex|r +## Notes: Learns quests, creatures and drops while you play: who gives and takes a quest, rewards, objectives, drop chances. +## Notes-deDE: Lernt beim Spielen Quests, Kreaturen und Beute: wer eine Quest vergibt und annimmt, Belohnungen, Ziele, Drop-Chancen. +## Author: Lacustat +## Version: 0.1.136 +## Dependencies: Freewood-OctoWoW-Core +## OptionalDeps: Freewood-OctoWoW-CodexData, Freewood-OctoWoW-ForeverData + +Locales.lua +Codex.lua +CodexOwners.lua +CodexTrust.lua +CodexNames.lua +CodexSeed.lua +CodexRoles.lua +CodexRecipes.lua +CodexTime.lua +CodexShare.lua +CodexZones.lua +CodexPath.lua +CodexMapAPI.lua +CodexPathMap.lua +CodexTaxi.lua +CodexTaxiUI.lua +CodexTransport.lua +CodexSpots.lua +CodexForever.lua +CodexAvail.lua +CodexCatalog.lua +CodexQuestProbe.lua +CodexWdbChain.lua +CodexMapScan.lua +CodexSync.lua +CodexLookup.lua +CodexMap.lua +CodexUI.lua +CodexHints.lua +CodexFavUI.lua +CodexNameUI.lua +CodexShareUI.lua diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Locales.lua b/Locales.lua new file mode 100644 index 0000000..9b0cf9b --- /dev/null +++ b/Locales.lua @@ -0,0 +1,1095 @@ +-- Freewood-OctoWoW-Codex: Texte +local ADDON, NS = ... + +local FW = _G.Freewood +if not FW then + return +end + +FW:AddLocale("enUS", { + CODEX_SEC_EFFECTS = "Effects", + CODEX_FX_SKILL = "Skill: %s", + CODEX_FX_FW_SWIM = "Swim speed (%)", + CODEX_FX_FW_RUNSPEED = "Run speed (%)", + CODEX_FX_FW_MOUNTSPEED = "Mount speed (%)", + CODEX_FX_FW_CRIT = "Crit chance (%)", + CODEX_FX_FW_SPELLCRIT = "Spell crit chance (%)", + CODEX_FX_FW_RANGEDCRIT = "Ranged crit chance (%)", + CODEX_FX_FW_HIT = "Hit chance (%)", + CODEX_FX_FW_SPELLHIT = "Spell hit chance (%)", + CODEX_FX_FW_DODGE = "Dodge chance (%)", + CODEX_FX_FW_PARRY = "Parry chance (%)", + CODEX_FX_FW_BLOCK = "Block chance (%)", + CODEX_FX_FW_BLOCKVAL = "Block value", + CODEX_FX_FW_MP5 = "Mana per 5 sec", + CODEX_FX_FW_HP5 = "Health per 5 sec", + CODEX_FX_FW_DEFENSE = "Defense skill", + CODEX_FX_FW_FISHING = "Fishing skill", + CODEX_FX_FW_HASTE = "Attack and casting speed (%)", + CODEX_FX_FW_RANGEDSPEED = "Ranged attack speed (%)", + CODEX_FX_FW_ARMORPEN = "Armor ignored", + CODEX_FX_FW_SPELLPEN = "Spell penetration", + CODEX_FX_FW_LIFESTEAL = "Damage returned as healing (%)", + CODEX_FX_FW_AMMODPS = "Ammo damage per second", + CODEX_FX_FW_SD_ARCANE = "Arcane spell damage", + CODEX_FX_FW_SD_FIRE = "Fire spell damage", + CODEX_FX_FW_SD_FROST = "Frost spell damage", + CODEX_FX_FW_SD_HOLY = "Holy spell damage", + CODEX_FX_FW_SD_NATURE = "Nature spell damage", + CODEX_FX_FW_SD_SHADOW = "Shadow spell damage", + CODEX_FX_FW_PROC = "Effect: chance on hit", + CODEX_FX_FW_USE = "Effect: on use", + CODEX_FX_FW_SET = "Set bonus", + CODEX_FX_FW_EQUIPFX = "Effect: other equip effect", + CODEX_FX_FW_EXTRADMG = "Extra weapon damage (school)", + CODEX_TITLE = "Database", + CODEX_INSTANCE_UNKNOWN = "Instance #%d", + CODEX_PROPS_BTN = "Properties", + CODEX_PROPS_ZONES = "Zones (where it can be found)", + CODEX_PROPS_ZONES_NONE = "No zone set.", + CODEX_PROPS_HERE = "Add my current zone", + CODEX_PROPS_ZONE_HINT = "Type a zone or dungeon name (or a map ID) and pick it from the list. Useful for instances, where the game gives no locations.", + CODEX_PROPS_DUNGEON = "Dungeon / instance:", + CODEX_PROPS_CLASS = "Class:", + CODEX_PROPS_LEVELS = "Level (from - to):", + CODEX_PROPS_PRE = "Prerequisite quest IDs:", + CODEX_PROPS_NEXT = "Follow-up quest IDs:", + CODEX_PROPS_BEFORE = "Comes before quest IDs:", + CODEX_PROPS_AFTER = "Comes after quest IDs:", + CODEX_PROPS_QLEVEL = "Quest level:", + CODEX_PROPS_TEXT = "Quest text:", + CODEX_SEC_QTEXT = "Quest text (by editors)", + CODEX_PROPS_KEY = "Key item ID:", + CODEX_PROPS_STARTITEM = "Starts from item ID:", + CODEX_PROPS_KEY_HINT = "The item that opens the chest this item is in (e.g. 4882). The levelling plan then counts where the key comes from.", + CODEX_PROPS_PRE_HINT = "Comma separated, any one of them is enough. Example: 794", + CODEX_PROPS_UNKNOWN = "unknown", + CODEX_PROPS_YES = "yes (dungeon)", + CODEX_PROPS_NO = "no (open world)", + CODEX_PROPS_AUTO = "automatic (as learned)", + CODEX_PROPS_CLASS_normal = "normal", + CODEX_PROPS_CLASS_elite = "elite", + CODEX_PROPS_CLASS_rare = "rare", + CODEX_PROPS_CLASS_rareelite = "rare elite", + CODEX_PROPS_CLASS_worldboss = "boss", + CODEX_PROPS_QK = "Kill target of quest IDs:", + CODEX_PROPS_QD = "Drops quest items (Quest:Item):", + CODEX_PROPS_Q_HINT = "Quest IDs separated by commas. Quest items as QuestID:ItemID, for example 6:1234 (this one drops item 1234 for quest 6).", + CODEX_SEC_QITEMS = "Drops quest items for", + CODEX_PROPS_QB = "Belongs to quest IDs:", + CODEX_PROPS_QR = "Belongs to this quest:", + CODEX_PROPS_QR_HINT = "Items, creatures, objects: i:1234, u:38, o:55 (i = item, u = creature, o = object). They need not be in the database.", + CODEX_SEC_QREL = "Belongs to this quest", + CODEX_SEC_QTEXT = "Quest text", + CODEX_QT_D = "Description", + CODEX_QT_O = "Objective", + CODEX_QT_P = "In progress", + CODEX_QT_R = "On completion", + CODEX_CT_beast = "Beast", CODEX_CT_humanoid = "Humanoid", CODEX_CT_undead = "Undead", CODEX_CT_demon = "Demon", + CODEX_CT_dragonkin = "Dragonkin", CODEX_CT_elemental = "Elemental", CODEX_CT_giant = "Giant", + CODEX_CT_mechanical = "Mechanical", CODEX_CT_critter = "Critter", CODEX_CT_totem = "Totem", CODEX_CT_gascloud = "Gas cloud", CODEX_CT_other = "Not specified", CODEX_CT_pet = "Non-combat pet", + CODEX_SEC_QBEL = "Belongs to quest", + CODEX_MANUAL_TAG = "by hand", + CODEX_PROPS_HINT = "Saved values wait in the Dev tab until you press \"Send changes\". They override what the game learned and go to all players. Items are shared as well.", + CODEX_PROPS_LINE = "Properties (by editors)", + CODEX_PROPS_DG_YES = "Dungeon", + CODEX_PROPS_DG_NO = "Open world", + CODEX_PROPS_ZONES_LINE = "Zones: %s", + CODEX_ROLE_INFO = "Your key: %s, role: %s, Dev module: %s, permission list: %d entries (revision %d)", + CODEX_DESC = "Learns quests, creatures and drops while you play.", + CODEX_TAB = "Database", + + CODEX_KIND_QUESTS = "Quests", + CODEX_KIND_UNITS = "Creatures", + CODEX_KIND_OBJECTS = "Objects", + CODEX_KIND_ITEMS = "Items", + CODEX_SEARCH = "Search ...", + CODEX_SORT_NAME = "Sort: name", + CODEX_SORT_RECENT = "Sort: recent", + CODEX_COUNT = "%d entries", + CODEX_COUNT_MORE = "%d entries (first %d shown, narrow the search)", + CODEX_EMPTY = "Nothing recorded yet. Play on: quests, creatures and loot are learned automatically.", + CODEX_NO_MATCH = "No entries match.", + CODEX_SELECT = "Select an entry on the left.", + + CODEX_LEVEL = "Level %s", + CODEX_LEVEL_RANGE = "Level %d-%d", + CODEX_SEEN = "accepted %dx", + CODEX_DONE = "completed %dx", + CODEX_PARTIAL = "found in the quest log, no giver recorded yet", + + CODEX_SEC_GIVE = "Accepted from", + CODEX_GIVE_BY_ITEM = "Started by an item (sources: see item entry)", + CODEX_START_ITEM = "start item", + CODEX_SEC_TURN = "Turned in to", + CODEX_SEC_REWARD = "Rewards", + CODEX_SEC_OBJ = "Objectives", + CODEX_SEC_GIVES = "Gives quests", + CODEX_SEC_TURNS = "Takes quests", + CODEX_SEC_TARGET = "Objective of quests", + CODEX_SEC_LOOT = "Loot", + CODEX_SEC_ALT = "Other loot (skinning and similar)", + CODEX_SEC_DROPS = "Drops from", + CODEX_SEC_STATS = "Stats", + CODEX_SEED_ON = "Template: on", + CODEX_SEED_OFF = "Template: off", + CODEX_SEED_TAG = "template", + CODEX_CAT_TAG = "catalog", + CODEX_START_TAG = "start data", + CODEX_START_NOTE = "From the bundled start data (pfQuest/Octo), not confirmed by you yet. Your own observations always win.", + CODEX_CAT_NOTE = "Title, level, objectives, rewards and texts come from the server quest catalog. Giver, turn-in and prerequisites are learned by playing and talking to NPCs.", + CODEX_CAT_TARGET = "target of %d quest(s)", + CODEX_SEC_REP = "Reputation goal", + CODEX_SEED_NOTE = "From the Vanilla template, not confirmed in Forever yet. Your own observations always win.", + CODEX_SEED_MISSING = "Template data not installed (addon Freewood-OctoWoW-CodexData).", + CODEX_SEED_STATUS = "Template: %d quests, %d creatures, %d objects, %d items loaded.", + CODEX_SEC_PRE = "Prerequisite quests", + CODEX_SEC_NEXT = "Follow-up quests", + CODEX_MINLEVEL = "Requires level %d", + CODEX_SEC_LOOT_SEED = "Loot (template)", + CODEX_SEC_SELLS = "Sells (template)", + CODEX_SEC_SOLDBY = "Sold by (template)", + CODEX_SEC_SRC_SEED = "Sources (template)", + CODEX_SEC_SRC_START = "Sources (start data)", + CODEX_SEC_SOLDBY_START = "Sold by (start data)", + CODEX_SEED_CHANCE = "~%s%%", + CODEX_SRC_VENDOR = "Vendor", + CODEX_FILTER_ROLE = "Role: %s", + CODEX_FILTER_CHAIN = "Chain: %s", + CODEX_CHAIN_POS = "Chain %d/%d", + CODEX_FILTER_STATUS = "Status: %s", + CODEX_STATUS_DONE = "completed", + CODEX_STATUS_ACTIVE = "in progress", + CODEX_STATUS_OPEN = "not started", + CODEX_STATUS_LINE = "This character: %s", + CODEX_OP_AND = "AND", + CODEX_OP_OR = "OR", + CODEX_CHAIN_IN = "in a chain", + CODEX_CHAIN_START = "start", + CODEX_CHAIN_MID = "middle", + CODEX_CHAIN_END = "end", + CODEX_CHAIN_NONE = "standalone", + CODEX_SORT_DIST = "Sort: distance", + CODEX_WEAPON_DAMAGE = "Damage", + CODEX_WEAPON_SPEED = "Speed", + CODEX_FAC_A = "Alliance", + CODEX_FAC_H = "Horde", + CODEX_SORTBY_name = "Name", + CODEX_SORTBY_recent = "Recent", + CODEX_SORTBY_gold = "Gold per kill", + CODEX_SORTBY_ttk = "Time to kill", + CODEX_SORTBY_gpm = "Gold per minute", + CODEX_SORTBY_ahkill = "Value per kill (auction)", + CODEX_SORTBY_gpmah = "Gold per minute (auction)", + CODEX_FILTER_AH = "Auction (g)", + CODEX_BAD_MARK = "Mark as faulty", + CODEX_BAD_UNMARK = "Unmark faulty", + CODEX_SORTBY_flip = "Vendor profit (auction)", + CODEX_AH_AGE = "Last scan", + CODEX_AH_TODAY = "today", + CODEX_AH_DAYS = "%d days ago", + CODEX_AH_OLD = "old", + CODEX_AH_THIN = "~ Only a few listings: the price may be inaccurate.", + CODEX_AH_TREND_DAY = "Change since last scan day", + CODEX_AH_TREND_ALL = "Change over the whole period", + CODEX_AH_NET = "Net after 5 % fee", + CODEX_AH_VENDOR_SELL = "Vendor sell price", + CODEX_AH_SELL_AH = "Auction pays more by", + CODEX_AH_SELL_VENDOR = "Vendor pays more by", + CODEX_AH_VENDOR_BUY = "Buy from vendor", + CODEX_AH_FLIP = "Profit per item (buy at vendor, sell at auction)", + CODEX_AH_SHORT = "AH", + CODEX_SRV_NOTE = "Only name and type are known (from the server). Positions, levels and loot are learned as you play.", + CODEX_SRV_SPELL = "Teaches spell (ID)", + CODEX_SEC_STARTS = "Starts the quest", + CODEX_AH_TAG = "auction house", + CODEX_AH_NOTE = "Only known from the auction scan. Source, drop and use are learned as soon as someone plays it.", + CODEX_VALUE_ITEMS_AH = "Items (auction prices)", + CODEX_VALUE_TOTAL_AH = "Total (auction)", + CODEX_SEC_AH = "Auction house", + CODEX_AH_PRICE = "Price (as set)", + CODEX_AH_MIN = "Lowest", + CODEX_AH_AVG = "Average", + CODEX_AH_QTY = "Listed", + CODEX_SORTBY_lvl = "Level", + CODEX_SORTBY_xpm = "XP per minute", + CODEX_SORTBY_dist = "Distance", + CODEX_SORTBY_xp = "Experience", + CODEX_SORTBY_money = "Gold reward", + CODEX_SORTBY_qtime = "Quest time", + CODEX_SORTBY_price = "Sell value", + CODEX_SORTBY_buy = "Vendor price", + CODEX_SORTBY_ah = "Auction price", + CODEX_SORTBY_ilvl = "Item level", + CODEX_SORTBY_quality = "Quality", + CODEX_SORTBY_chance = "Drop chance", + CODEX_SORTBY_gph = "Gold per hour", + CODEX_SORT_DIR_TIP = "Reverse sort order", + CODEX_FARM = "Farm ranking", + CODEX_BACK = "< Back", + CODEX_SEC_BESTSRC = "Best place to farm", + CODEX_BESTSRC_ROW = "%s / h | %.0f s per kill", + CODEX_CHEAPEST = "cheapest", + CODEX_SORTBY_data = "Data (least first)", + CODEX_ROLE_combat = "Combat creature", + CODEX_ROLE_elite = "Elite", + CODEX_ROLE_boss = "Boss", + CODEX_ROLE_classtrainer = "Class trainer", + CODEX_ROLE_proftrainer = "Profession trainer", + CODEX_SEC_WARES = "Wares (%d)", + CODEX_SEC_TRAINS = "Teaches (%d)", + CODEX_SEC_FLIGHTS = "Flight destinations (%d)", + CODEX_SEC_SOLDBY_LIVE = "Sold by (observed)", + CODEX_TITLE_LINE = "Title: %s", + CODEX_WARE_LIMITED = "limited", + CODEX_WARE_TOKEN = "tokens", + CODEX_TRAIN_LEVEL = "Level %d", + CODEX_FLIGHT_FROM = "Flight point: %s", + CODEX_ROLES_EVENTS = "Events seen: %s", + CODEX_ROLES_GOSSIP = "Gossip/frame kinds seen: %s", + CODEX_ROLES_COUNTS = "Roles recorded: %s", + CODEX_ROLES_LAST = "Last recorded: %s (%s %s)", + CODEX_ROLE_vendor = "Vendor", + CODEX_ROLE_repair = "Repair", + CODEX_ROLE_flight = "Flight master", + CODEX_ROLE_innkeeper = "Innkeeper", + CODEX_ROLE_stablemaster = "Stable master", + CODEX_ROLE_spirithealer = "Spirit healer", + CODEX_ROLE_auctioneer = "Auctioneer", + CODEX_ROLE_battlemaster = "Battlemaster", + CODEX_ROLE_banker = "Banker", + CODEX_ROLE_rares = "Rare creature", + CODEX_ROLE_mailbox = "Mailbox", + CODEX_ROLE_meetingstone = "Meeting stone", + CODEX_ROLE_chests = "Chest", + CODEX_ROLE_herbs = "Herb", + CODEX_ROLE_mines = "Ore", + CODEX_ROLE_fish = "Fishing pool", + CODEX_NEAR_HEAD = "Nearest (%s):", + CODEX_NEAR_ROW = "%s - %.1f / %.1f", + CODEX_NEAR_NONE = "Nothing found on this map (or no template data).", + CODEX_NEAR_HELP = "Usage: /fwcodex near ", + CODEX_SEC_TIME = "Time", + CODEX_TTK_AVG = "Time to kill (%s)", + CODEX_TTK_XPM = "XP per minute (fighting)", + CODEX_SEC_TTK_CLASSES = "Time to kill by class", + CODEX_TTK_YOU = "(you)", + CODEX_TTK_ROW = "%.1f s | %d XP/min", + CODEX_QT_EFF = "Active time, split across quests", + CODEX_QT_RAW = "Active time, unsplit", + CODEX_QT_ALL = "Until turn-in", + CODEX_QT_N = "Measurements", + CODEX_FILTER_STAT = "Stat: %s", + CODEX_FILTER_STATV = "Min / Max", + CODEX_SEC_REWARDOF = "Reward of quests", + CODEX_SEC_QUESTITEM = "Quest item for", + CODEX_SEC_PLACES = "Seen in", + + CODEX_XP = "Experience", + CODEX_MONEY = "Gold", + CODEX_REP = "Reputation", + CODEX_FIXED = "Reward", + CODEX_CHOICE = "Choice", + CODEX_CHOSEN = "chosen %dx", + CODEX_SOURCES = "Sources", + CODEX_KILLS = "Looted: %d", + CODEX_COIN_AVG = "Average gold: %s", + CODEX_DROP_CHANCE = "%s (%d of %d)", + CODEX_LOW_SAMPLE = "few samples", + CODEX_AVG_QTY = "avg. %.1f", + CODEX_QUEST_ITEM = "quest item", + CODEX_POS = "%s (%.0f, %.0f)", + CODEX_SEC_SPOTS = "Locations", + CODEX_SPOT_HITS = "%dx", + CODEX_MAP_SHOW = "Show on world map", + CODEX_MAP_LABEL = "Codex: %s - click to hide", + CODEX_MAP_NONE = "No locations recorded yet.", + CODEX_MAP_HOVER = "Locations nearby", + CODEX_MAP_MORE = "... and %d more", + CODEX_TAG_GIVE = "Accept: %s", + CODEX_TAG_TURN = "Turn in: %s", + + CODEX_TYPE_monster = "Kill", + CODEX_TYPE_item = "Collect", + CODEX_TYPE_object = "Object", + CODEX_TYPE_event = "Event", + CODEX_TYPE_other = "Objective", + CODEX_TYPE_log = "Objective", + + CODEX_KIND_ZONES = "Zones", + CODEX_SEC_ZONE_RATE = "Your measurement", + CODEX_ZONE_RATE_NOTE = "Measured while playing with your current Quest settings; changing a setting starts a new measurement.", + CODEX_ZONE_RATE_LINE = "Level %d", + CODEX_ZONE_RATE_LEVEL = ", level-up in ~%d min", + CODEX_FILTER_ZONE = "Type: %s", + CODEX_ZONEF_WORLD = "Open world", + CODEX_ZONEF_DUNGEON = "Dungeons", + CODEX_ZONEF_FIT = "Fits my level", + CODEX_ZONEF_DATA = "Enough data", + CODEX_SORTBY_zlvl = "Level range", + CODEX_SORTBY_cnt = "Creatures", + CODEX_ZONE_AVG = "avg", + CODEX_ZONE_MANUAL = "set by hand", + CODEX_ZONE_FEW = "few data", + CODEX_ZONE_UNITS = "%d creatures", + CODEX_ZONE_OUTLIERS = "%d outliers", + CODEX_ZONE_SPECIAL = "%d elite/rare/boss", + CODEX_ZONE_DUNGEON = "Dungeon", + CODEX_ZONE_WORLD = "Open world", + CODEX_SEC_ZONE_LEVELS = "Levels", + CODEX_ZONE_TYPICAL = "Typical range", + CODEX_ZONE_COUNT = "Creatures (normal)", + CODEX_ZONE_MANUAL_NOTE = "This range was set by hand.", + CODEX_ZONE_FEW_NOTE = "Too few creatures known: the range is only a rough guess.", + CODEX_ZONE_OUTLIER_LIST = "Outliers", + CODEX_ZONE_OUTLIER = "outlier", + CODEX_SEC_ZONE_UNITS = "Creatures in this zone (%d)", + CODEX_SEC_ZONE_EDIT = "Edit", + CODEX_SEC_ZONE_QUESTS = "Quests starting in this zone (%d)", + CODEX_SEC_ZONE_FORYOU = "For your level (%d, level %d)", + CODEX_SEC_ZONE_FLIGHT = "Flight points (%d)", + CODEX_ZONE_QMIN = "from level %d", + CODEX_ZONE_EDIT = "Set level range by hand...", + CODEX_ZONE_EDIT_HINT = "Replaces the computed range for everyone after sending in the Dev tab. Leave empty or clear to use the computed range again.", + CODEX_KIND_XP = "Experience", + CODEX_KIND_LEARN = "Learning plan", + CODEX_HINT_MOVE = "Move: ON", + CODEX_HINT_MOVE_DONE = "Done - save", + CODEX_HINT_RESET = "Reset position", + CODEX_HINT_DRAG = "Drag with the left mouse button", + CODEX_FAVX_HEADER = "Favorites and notes", + CODEX_FAVX_SECTION_NOTE = "Back up your favorites and notes as text, or move them to another character or computer.", + CODEX_FAVX_OPEN = "Export / import favorites...", + CODEX_FAVX_TITLE = "Export and import favorites", + CODEX_FAVX_NOTE = "Export creates one line of text (Ctrl+C to copy). To import, paste such a line (Ctrl+V) and press Import. Existing favorites and notes are kept, new ones are added.", + CODEX_FAVX_EXPORT = "Export", + CODEX_FAVX_IMPORT = "Import", + CODEX_FAVX_CLOSE = "Close", + CODEX_FAVX_EXPORTED = "%d favorites exported. Press Ctrl+C to copy.", + CODEX_FAVX_IMPORTED = "Imported: %d new favorites, %d notes taken over, %d invalid entries.", + CODEX_FAVX_BAD = "This is not an exported favorites line.", + CODEX_SORTBY_zone = "Area (grouped)", + CODEX_GROUP_ROW = "%d open | %d active | %d done", + CODEX_ZONE_UNKNOWN = "No area", + CODEX_LISTMAX_HEADER = "Database list length", + CODEX_LISTMAX_NOTE = "How many rows the database list shows at most. Higher values show more entries but can slow the window down; the list refreshes the next time it is built.", + CODEX_LISTMAX_VALUE = "Up to %d rows", + CODEX_HINT_HEADER = "Learning hints", + CODEX_HINT_NOTE = "After a level up (out of combat) or a new profession rank, a short hint in the middle of the screen shows what you can now learn from a trainer. Based on the trainers you or others have visited.", + CODEX_HINT_CLASS_SWITCH = "Hint after a level up: what to learn from the class trainer", + CODEX_PATHMAP_LABEL = "Path network: %d path, %d drops, %d crossings, %d gaps", + CODEX_PATHMAP_NONE = "Path network: nothing recorded on this map yet", + CODEX_PATH_HEADER = "Path network", + CODEX_PATH_NOTE = "Records where you walked on foot (not mounted, flying, falling or gliding, not in instances) as a grid of cells. Only a position and direction per cell are stored, no names and no times. The level plan will use it later to find walkable routes.", + CODEX_PATH_SWITCH = "Record walked paths", + CODEX_PATH_CLEANED = "Codex: %d old foreign path cells (from earlier start data) removed. Received paths will arrive again through sync.", + CODEX_TAXI_HEADER = "Flights", + CODEX_TAXI_NOTE = "Shows a small window with the remaining flight time while you travel by flight master.", + CODEX_TAXI_SWITCH = "Show flight window", + CODEX_TAXI_PLAN = "Use flights in the level planner (only between flight points this character knows)", + CODEX_TAXI_TITLE = "Flight to %s", + CODEX_TAXI_LEFT = "%s left", + CODEX_TAXI_ARRIVING = "Arriving...", + CODEX_TAXI_UNKNOWN = "%s (duration is being recorded)", + CODEX_TAXI_TIP_TIME = "Flight time: %s", + CODEX_TAXI_TIP_NONE = "Flight time: unknown (recorded on the first flight)", + CODEX_TAXI_TIP_COUNT = "Flown %dx, %s in total", + CODEX_PATH_STATS = "%d cells, %d connections, %d drops, %d zone links recorded", + CODEX_HINT_PROF_SWITCH = "Hint at a new profession rank: what to learn from the profession trainer", + CODEX_HINT_TEST = "Show a test hint", + CODEX_HINT_CLASS_TITLE = "Level %d: now learnable at your trainer", + CODEX_HINT_PROF_TITLE = "%s %d: now learnable at the trainer", + CODEX_HINT_MORE = "... and %d more", + CODEX_HINT_CLICK = "Click to open the learning plan", + CODEX_HINT_COST = "Cost %s", + CODEX_HINT_MISSING = "%s missing", + CODEX_HINT_DEMO = "Example ability", + CODEX_SORTBY_step = "Level (ascending)", + CODEX_LEARN_CLASS = "Class", + CODEX_LEARN_SUMSHORT = "total %s", + CODEX_LEARN_EMPTY = "No class trainer data yet. Visit class trainers, the plan fills itself.", + CODEX_LEARN_TITLE = "Level %d", + CODEX_LEARN_SUB = "%d abilities | cost %s", + CODEX_LEARN_CUM = "Total from level %d on: %s", + CODEX_LEARN_LVUNKNOWN = "Level unknown", + CODEX_SEC_LEARN_SPELLS = "Abilities (%d)", + CODEX_SEC_LEARN_TRAINERS = "Trainers (%d)", + CODEX_LEARN_NOTE = "Built from the trainers you or others have visited. Prices are the most common ones seen.", + CODEX_ROUTE = "Plan route", + CODEX_ROUTE_RANGE = "Quests in my level range (+/- 3)", + CODEX_ROUTE_BELOW = "Levels below", + CODEX_ROUTE_ABOVE = "Levels above", + CODEX_ROUTE_GO = "Calculate route", + CODEX_ROUTE_NONE = "No acceptable quests with a known position in your area in the list.", + CODEX_ROUTE_LABEL = "Quest route (%d stops)", + CODEX_ROUTE_TIP = "Opens the route planner: choose a level range, then calculate a route over the listed, acceptable quests in your area. It follows you while the map is open.", + CODEX_FAV_ONLY = "Favorites", + CODEX_FAV_ADD = "Add to favorites", + CODEX_FAV_REMOVE = "Remove from favorites", + CODEX_SEC_NOTE = "My note", + CODEX_NOTE_HINT = "Type a short note and press Enter...", + CODEX_DEVNOTE_HINT = "Dev note (only you see it, not shared)... press Enter to save", + CODEX_XP_TITLE = "You level %d > creature level %d", + CODEX_XP_ELITE = "Elite", + CODEX_XP_RARE = "Rare", + CODEX_XP_BOSS = "Boss", + CODEX_XP_ROW = "avg. %.1f | formula %d | diff %+.1f | n=%d", + CODEX_XP_BONUS_TAG = "bonus", + CODEX_XP_RESTED_TAG = "rested", + CODEX_XP_WITH_RESTED = "with rested bonus", + CODEX_SEC_XP = "Experience per kill", + CODEX_SEC_XPCALC = "Formula (Classic 1-60)", + CODEX_SEC_XPMEAS = "Measured", + CODEX_XP_SAMPLES = "Samples", + CODEX_XP_AVG = "Average", + CODEX_XP_MINMAX = "Min / max", + CODEX_XP_PRED = "Formula value", + CODEX_XP_DIFF = "Difference", + CODEX_XP_COLOR = "Creature color", + CODEX_XP_GRAY = "Gray up to creature level", + CODEX_XP_ZD = "Zero difference (ZD)", + CODEX_XP_BASE = "Base (5 x level + 45; enemy level if lower)", + CODEX_XP_NOTE = "Samples with rested, group or raid bonus are listed separately and not used for the formula check.", + CODEX_XP_UNIT_ROW = "Level %d", + CODEX_XP_UNIT_ROW2 = "Level %d (enemy %d)", + CODEX_SORT_PRICE = "Sort: price", + CODEX_SORT_ILVL = "Sort: item level", + CODEX_SORT_QUALITY = "Sort: quality", + CODEX_SORT_CHANCE = "Sort: drop chance", + CODEX_SORT_GOLD = "Sort: gold/kill", + CODEX_FILTER = "Filter", + CODEX_FILTER_N = "Filter (%d)", + CODEX_FILTER_RESET = "Reset", + CODEX_FILTER_TYPE = "Type: %s", + CODEX_FILTER_SUB = "Subtype: %s", + CODEX_FILTER_SLOT = "Slot: %s", + CODEX_FILTER_SRC = "Source: %s", + CODEX_FILTER_ZONE = "Zone: %s", + CODEX_FILTER_ALL = "all", + CODEX_FILTER_ILVL = "Item level", + CODEX_FILTER_RLVL = "Req. level", + CODEX_FILTER_PRICE = "Price (g)", + CODEX_SRC_UNIT = "Creatures", + CODEX_SRC_OBJECT = "Objects", + CODEX_SRC_REWARD = "Quest reward", + CODEX_SRC_QUEST = "Quest item", + CODEX_QUAL_0 = "Poor", + CODEX_QUAL_1 = "Common", + CODEX_QUAL_2 = "Uncommon", + CODEX_QUAL_3 = "Rare", + CODEX_QUAL_4 = "Epic", + CODEX_QUAL_5 = "Legendary", + CODEX_LOADING_ITEMS = "loading item data: %d", + CODEX_ILVL_SHORT = "iLvl %d", + CODEX_VALUE_HEAD = "Value per looted corpse", + CODEX_VALUE_COIN = "Gold (actually dropped)", + CODEX_VALUE_ITEMS = "Items (vendor price)", + CODEX_VALUE_TOTAL = "Total (average)", + CODEX_VALUE_MISSING = "%d items without price data yet", + CODEX_VALUE_SHORT = "avg %s", + CODEX_TEST_OBJ = "Objectives without a readable name: %d", + CODEX_COLOR_red = "red", + CODEX_COLOR_orange = "orange", + CODEX_COLOR_yellow = "yellow", + CODEX_COLOR_green = "green", + CODEX_COLOR_gray = "gray", + CODEX_FAMILY = "Family: %s", + CODEX_TEST_XP = "Experience: %d series measured, %d unknown messages", + CODEX_YES = "yes", + CODEX_NO = "no", + CODEX_TEST_API = "API: loot source %s, objectives %s, quest reward %s, reward hook %s", + CODEX_TEST_COUNTS = "Database: %d quests, %d creatures, %d objects, %d item names", + CODEX_TEST_STATS = "This session: %d loot sources recorded, %d skipped reads", + CODEX_REMOTE_HINT = "Includes %d of %d corpses from other players", + CODEX_REMOTE_ONLY = "Data from other players only", + CODEX_SHARE_HEADER = "Share database", + CODEX_SHARE_NOTE = "Shares what you have recorded (creatures, objects, quests, drops, experience, walked paths and zone links) with guild, party, raid and a hidden channel, and receives the same from others. Only game data is sent, no character names. Received data is kept apart from yours and added to the displayed numbers.", + CODEX_SHARE_SWITCH = "Share database data", + CODEX_SHARE_SWITCH_TIP = "One switch for everything: guild, party, raid and the hidden channel. Off by default.", + CODEX_SHARE_STATS = "Sent: %d records | received: %d records", + CODEX_SHARE_SOURCES = "Stored from %d players: %d records", + CODEX_SHARE_GLOBAL_OK = "Hidden channel: connected", + CODEX_SHARE_GLOBAL_JOINING = "Hidden channel: joining ...", + CODEX_SHARE_GLOBAL_FAILED = "Hidden channel: could not join", + CODEX_SHARE_GLOBAL_UNAVAILABLE = "Hidden channel: not available", + CODEX_SHARE_GLOBAL_OFF = "Hidden channel: off", + CODEX_SHARE_HEARD = "Players heard from in the last hour: %d", + CODEX_SHARE_LOG = "Show sharing log in chat (troubleshooting)", + CODEX_SHARE_LOG_TIP = "Prints every sent (->) and received (<-) sharing message in the chat. Off again after /reload.", + CODEX_SHARE_NOW = "Call others now", + CODEX_AVAIL_CLEARED = "All 'not possible yet' marks removed.", + CODEX_AVAIL_UNBLOCKED = "Quest %d is allowed in the plan again.", + CODEX_AVAIL_LIST = "Marked 'not possible yet': %d (/fwblock allows one again, /fwblock all allows all)", + CODEX_FOREVER_ON = "Starter data (ForeverData): %d records loaded (version %s). /fwforever off turns it off.", + CODEX_FOREVER_OFF = "Starter data (ForeverData) is off. /fwforever on turns it on.", + CODEX_FOREVER_MISSING = "Starter data (ForeverData) not found: the Freewood-OctoWoW-ForeverData addon is not installed.", + CODEX_SHARE_CLEAR = "Delete received data", + CODEX_SHARE_CLEAR_SURE = "Really delete? Click again", + CODEX_SHARE_CLEARED = "Codex: %d received records deleted.", + CODEX_EXPORT_LABEL = "Database", + CODEX_IMPORT_STATS = "%d records imported, %d skipped", + CODEX_TEST_SHARE = "Sharing: %s, %d own records, %d players with %d records stored", + CODEX_ON = "on", + CODEX_OFF = "off", + CODEX_NAME_EDIT = "Edit name", + CODEX_NAME_IMPORT = "Paste names", + CODEX_NAME_DE = "German", + CODEX_NAME_EN = "English", + CODEX_NAME_GAME = "Learned from the game: %s", + CODEX_NAME_SAVE = "Save", + CODEX_NAME_CLEAR = "Clear names", + CODEX_NAME_CANCEL = "Cancel", + CODEX_NAME_HINT = "Shared with other players. Your own entry always wins.", + CODEX_NAME_HINT_LOCAL = "Item names stay on this computer.", + CODEX_NAME_LINK = "Look it up: click the link, press Ctrl+C, paste it into your browser.", + CODEX_NAME_KILLS = "Recently defeated (click to use):", + CODEX_NAME_NOKILLS = "No kill lines seen yet. Kill something and open this again.", + CODEX_NAME_IMPORT_HELP = "One line per entry: ID, space, name (also ID;name or ID=name). Applies to the current list: %s.", + CODEX_NAME_IMPORT_GO = "Import", + CODEX_NAME_IMPORT_RESULT = "%d saved, %d skipped", + CODEX_NAME_CLOSE = "Close", +}) + +FW:AddLocale("deDE", { + CODEX_SEC_EFFECTS = "Effekte", + CODEX_FX_SKILL = "Fertigkeit: %s", + CODEX_FX_FW_SWIM = "Schwimmtempo (%)", + CODEX_FX_FW_RUNSPEED = "Lauftempo (%)", + CODEX_FX_FW_MOUNTSPEED = "Reittempo (%)", + CODEX_FX_FW_CRIT = "Kritische Trefferchance (%)", + CODEX_FX_FW_SPELLCRIT = "Kritische Zaubertrefferchance (%)", + CODEX_FX_FW_RANGEDCRIT = "Kritische Fernkampfchance (%)", + CODEX_FX_FW_HIT = "Trefferchance (%)", + CODEX_FX_FW_SPELLHIT = "Zaubertrefferchance (%)", + CODEX_FX_FW_DODGE = "Ausweichchance (%)", + CODEX_FX_FW_PARRY = "Parierchance (%)", + CODEX_FX_FW_BLOCK = "Blockchance (%)", + CODEX_FX_FW_BLOCKVAL = "Blockwert", + CODEX_FX_FW_MP5 = "Mana pro 5 Sek.", + CODEX_FX_FW_HP5 = "Leben pro 5 Sek.", + CODEX_FX_FW_DEFENSE = "Verteidigung", + CODEX_FX_FW_FISHING = "Angeln", + CODEX_FX_FW_HASTE = "Angriffs- und Zaubertempo (%)", + CODEX_FX_FW_RANGEDSPEED = "Fernkampftempo (%)", + CODEX_FX_FW_ARMORPEN = "Ignorierte Rüstung", + CODEX_FX_FW_SPELLPEN = "Zauberdurchschlag", + CODEX_FX_FW_LIFESTEAL = "Schaden als Heilung zurück (%)", + CODEX_FX_FW_AMMODPS = "Munition: Schaden pro Sek.", + CODEX_FX_FW_SD_ARCANE = "Arkanzauberschaden", + CODEX_FX_FW_SD_FIRE = "Feuerzauberschaden", + CODEX_FX_FW_SD_FROST = "Frostzauberschaden", + CODEX_FX_FW_SD_HOLY = "Heiligzauberschaden", + CODEX_FX_FW_SD_NATURE = "Naturzauberschaden", + CODEX_FX_FW_SD_SHADOW = "Schattenzauberschaden", + CODEX_FX_FW_PROC = "Effekt: Chance bei Treffer", + CODEX_FX_FW_USE = "Effekt: bei Benutzung", + CODEX_FX_FW_SET = "Set-Bonus", + CODEX_FX_FW_EQUIPFX = "Effekt: sonstiger Anlege-Effekt", + CODEX_FX_FW_EXTRADMG = "Zusätzlicher Waffenschaden (Schule)", + CODEX_TITLE = "Datenbank", + CODEX_INSTANCE_UNKNOWN = "Instanz #%d", + CODEX_PROPS_BTN = "Eigenschaften", + CODEX_PROPS_ZONES = "Zonen (wo man es findet)", + CODEX_PROPS_ZONES_NONE = "Keine Zone eingetragen.", + CODEX_PROPS_HERE = "Meine aktuelle Zone", + CODEX_PROPS_ZONE_HINT = "Zonen- oder Dungeon-Namen (oder eine Karten-ID) eintippen und aus der Liste wählen. Praktisch für Instanzen, in denen das Spiel keine Orte liefert.", + CODEX_PROPS_DUNGEON = "Dungeon / Instanz:", + CODEX_PROPS_CLASS = "Klasse:", + CODEX_PROPS_LEVELS = "Stufe (von - bis):", + CODEX_PROPS_PRE = "Vorquest-IDs:", + CODEX_PROPS_NEXT = "Folgequest-IDs:", + CODEX_PROPS_BEFORE = "Kommt vor Quest-IDs:", + CODEX_PROPS_AFTER = "Kommt nach Quest-IDs:", + CODEX_PROPS_QLEVEL = "Quest-Stufe:", + CODEX_PROPS_TEXT = "Questtext:", + CODEX_SEC_QTEXT = "Questtext (von Bearbeitern)", + CODEX_PROPS_KEY = "Schlüssel-Item-ID:", + CODEX_PROPS_STARTITEM = "Startet durch Item-ID:", + CODEX_PROPS_KEY_HINT = "Das Item, das die Truhe öffnet, in der dieses Item liegt (z. B. 4882). Der Levelplan rechnet dann ein, woher der Schlüssel kommt.", + CODEX_PROPS_PRE_HINT = "Durch Komma getrennt, eine davon genügt. Beispiel: 794", + CODEX_PROPS_UNKNOWN = "unbekannt", + CODEX_PROPS_YES = "ja (Dungeon)", + CODEX_PROPS_NO = "nein (offene Welt)", + CODEX_PROPS_AUTO = "automatisch (wie gelernt)", + CODEX_PROPS_CLASS_normal = "normal", + CODEX_PROPS_CLASS_elite = "Elite", + CODEX_PROPS_CLASS_rare = "Selten", + CODEX_PROPS_CLASS_rareelite = "Selten-Elite", + CODEX_PROPS_CLASS_worldboss = "Boss", + CODEX_PROPS_QK = "Kill-Ziel von Quest-IDs:", + CODEX_PROPS_QD = "Lässt Quest-Items fallen (Quest:Item):", + CODEX_PROPS_Q_HINT = "Quest-IDs durch Komma getrennt. Quest-Items als QuestID:ItemID, zum Beispiel 6:1234 (lässt Item 1234 für Quest 6 fallen).", + CODEX_SEC_QITEMS = "Lässt Quest-Items fallen für", + CODEX_PROPS_QB = "Gehört zu Quest-IDs:", + CODEX_PROPS_QR = "Gehört zu dieser Quest:", + CODEX_PROPS_QR_HINT = "Items, Kreaturen, Objekte: i:1234, u:38, o:55 (i = Item, u = Kreatur, o = Objekt). Sie müssen nicht in der Datenbank stehen.", + CODEX_SEC_QREL = "Gehört zu dieser Quest", + CODEX_SEC_QTEXT = "Quest-Text", + CODEX_QT_D = "Beschreibung", + CODEX_QT_O = "Ziel", + CODEX_QT_P = "Zwischentext", + CODEX_QT_R = "Abschlusstext", + CODEX_CT_beast = "Wildtier", CODEX_CT_humanoid = "Humanoid", CODEX_CT_undead = "Untoter", CODEX_CT_demon = "Dämon", + CODEX_CT_dragonkin = "Drachkin", CODEX_CT_elemental = "Elementar", CODEX_CT_giant = "Riese", + CODEX_CT_mechanical = "Mechanisch", CODEX_CT_critter = "Kleintier", CODEX_CT_totem = "Totem", CODEX_CT_gascloud = "Gaswolke", CODEX_CT_other = "Nicht spezifiziert", CODEX_CT_pet = "Haustier", + CODEX_SEC_QBEL = "Gehört zur Quest", + CODEX_MANUAL_TAG = "von Hand", + CODEX_PROPS_HINT = "Gespeicherte Werte warten im Dev-Reiter, bis du „Änderungen senden“ drückst. Sie ersetzen, was das Spiel gelernt hat, und gehen an alle Spieler. Items werden ebenfalls geteilt.", + CODEX_PROPS_LINE = "Eigenschaften (von Bearbeitern)", + CODEX_PROPS_DG_YES = "Dungeon", + CODEX_PROPS_DG_NO = "Offene Welt", + CODEX_PROPS_ZONES_LINE = "Zonen: %s", + CODEX_ROLE_INFO = "Dein Schlüssel: %s, Rolle: %s, Dev-Modul: %s, Berechtigungsliste: %d Einträge (Stand %d)", + CODEX_DESC = "Lernt beim Spielen Quests, Kreaturen und Beute.", + CODEX_TAB = "Datenbank", + + CODEX_KIND_QUESTS = "Quests", + CODEX_KIND_UNITS = "Kreaturen", + CODEX_KIND_OBJECTS = "Objekte", + CODEX_KIND_ITEMS = "Items", + CODEX_SEARCH = "Suchen ...", + CODEX_SORT_NAME = "Sortierung: Name", + CODEX_SORT_RECENT = "Sortierung: zuletzt", + CODEX_COUNT = "%d Einträge", + CODEX_COUNT_MORE = "%d Einträge (die ersten %d sichtbar, Suche eingrenzen)", + CODEX_EMPTY = "Noch nichts erfasst. Spiele weiter: Quests, Kreaturen und Beute werden automatisch gelernt.", + CODEX_NO_MATCH = "Keine passenden Einträge.", + CODEX_SELECT = "Wähle links einen Eintrag.", + + CODEX_LEVEL = "Stufe %s", + CODEX_LEVEL_RANGE = "Stufe %d-%d", + CODEX_SEEN = "%dx angenommen", + CODEX_DONE = "%dx abgegeben", + CODEX_PARTIAL = "im Questlog gefunden, Geber noch nicht erfasst", + + CODEX_SEC_GIVE = "Angenommen bei", + CODEX_GIVE_BY_ITEM = "Angenommen durch ein Item (Fundorte: siehe Item-Eintrag)", + CODEX_START_ITEM = "Startitem", + CODEX_SEC_TURN = "Abgegeben bei", + CODEX_SEC_REWARD = "Belohnung", + CODEX_SEC_OBJ = "Ziele", + CODEX_SEC_GIVES = "Vergibt Quests", + CODEX_SEC_TURNS = "Nimmt Quests an", + CODEX_SEC_TARGET = "Ziel von Quests", + CODEX_SEC_LOOT = "Beute", + CODEX_SEC_ALT = "Sonstige Beute (Häuten und Ähnliches)", + CODEX_SEC_DROPS = "Fällt bei", + CODEX_SEC_STATS = "Werte", + CODEX_SEED_ON = "Vorlage: an", + CODEX_SEED_OFF = "Vorlage: aus", + CODEX_SEED_TAG = "Vorlage", + CODEX_CAT_TAG = "Katalog", + CODEX_START_TAG = "Startdaten", + CODEX_START_NOTE = "Aus den mitgelieferten Startdaten (pfQuest/Octo), von dir noch nicht bestätigt. Deine eigenen Beobachtungen gehen immer vor.", + CODEX_CAT_NOTE = "Titel, Stufe, Ziele, Belohnungen und Texte stammen aus dem Quest-Katalog des Servers. Geber, Abgabe und Vorquests werden beim Spielen und Sprechen mit NPCs gelernt.", + CODEX_CAT_TARGET = "Ziel von %d Quest(s)", + CODEX_SEC_REP = "Ruf-Ziel", + CODEX_SEED_NOTE = "Aus der Vanilla-Vorlage, in Forever noch nicht bestätigt. Deine eigenen Beobachtungen gehen immer vor.", + CODEX_SEED_MISSING = "Vorlagen-Daten nicht installiert (Addon Freewood-OctoWoW-CodexData).", + CODEX_SEED_STATUS = "Vorlage: %d Quests, %d Kreaturen, %d Objekte, %d Items geladen.", + CODEX_SEC_PRE = "Vorquests", + CODEX_SEC_NEXT = "Folgequests", + CODEX_MINLEVEL = "Benötigt Stufe %d", + CODEX_SEC_LOOT_SEED = "Beute (Vorlage)", + CODEX_SEC_SELLS = "Verkauft (Vorlage)", + CODEX_SEC_SOLDBY = "Verkauft von (Vorlage)", + CODEX_SEC_SRC_SEED = "Quellen (Vorlage)", + CODEX_SEC_SRC_START = "Quellen (Startdaten)", + CODEX_SEC_SOLDBY_START = "Verkauft von (Startdaten)", + CODEX_SEED_CHANCE = "ca. %s%%", + CODEX_SRC_VENDOR = "Händler", + CODEX_FILTER_ROLE = "Rolle: %s", + CODEX_FILTER_CHAIN = "Kette: %s", + CODEX_CHAIN_POS = "Kette %d/%d", + CODEX_FILTER_STATUS = "Status: %s", + CODEX_STATUS_DONE = "erledigt", + CODEX_STATUS_ACTIVE = "in Arbeit", + CODEX_STATUS_OPEN = "offen", + CODEX_STATUS_LINE = "Dieser Charakter: %s", + CODEX_OP_AND = "UND", + CODEX_OP_OR = "ODER", + CODEX_CHAIN_IN = "in einer Kette", + CODEX_CHAIN_START = "Anfang", + CODEX_CHAIN_MID = "Mitte", + CODEX_CHAIN_END = "Ende", + CODEX_CHAIN_NONE = "einzeln", + CODEX_SORT_DIST = "Sortierung: Entfernung", + CODEX_WEAPON_DAMAGE = "Schaden", + CODEX_WEAPON_SPEED = "Tempo", + CODEX_FAC_A = "Allianz", + CODEX_FAC_H = "Horde", + CODEX_SORTBY_name = "Name", + CODEX_SORTBY_recent = "Zuletzt", + CODEX_SORTBY_gold = "Gold pro Kill", + CODEX_SORTBY_ttk = "Kampfdauer (TTK)", + CODEX_SORTBY_gpm = "Gold pro Minute", + CODEX_SORTBY_ahkill = "Wert pro Kill (Auktion)", + CODEX_SORTBY_gpmah = "Gold pro Minute (Auktion)", + CODEX_FILTER_AH = "Auktion (g)", + CODEX_BAD_MARK = "Fehlerhaft markieren", + CODEX_BAD_UNMARK = "Markierung aufheben", + CODEX_SORTBY_flip = "Händlergewinn (Auktion)", + CODEX_AH_AGE = "Letzter Scan", + CODEX_AH_TODAY = "heute", + CODEX_AH_DAYS = "vor %d Tagen", + CODEX_AH_OLD = "alt", + CODEX_AH_THIN = "~ Nur wenige Angebote: der Preis kann ungenau sein.", + CODEX_AH_TREND_DAY = "Veränderung seit dem letzten Tag", + CODEX_AH_TREND_ALL = "Veränderung im ganzen Zeitraum", + CODEX_AH_NET = "Netto nach 5 % Gebühr", + CODEX_AH_VENDOR_SELL = "Verkaufspreis beim Händler", + CODEX_AH_SELL_AH = "Auktion bringt mehr um", + CODEX_AH_SELL_VENDOR = "Händler bringt mehr um", + CODEX_AH_VENDOR_BUY = "Einkauf beim Händler", + CODEX_AH_FLIP = "Gewinn je Stück (Händler kaufen, Auktion verkaufen)", + CODEX_AH_SHORT = "AH", + CODEX_SRV_NOTE = "Nur Name und Typ sind bekannt (vom Server). Positionen, Stufen und Beute werden beim Spielen gelernt.", + CODEX_SRV_SPELL = "Lehrt Zauber (ID)", + CODEX_SEC_STARTS = "Startet die Quest", + CODEX_AH_TAG = "Auktionshaus", + CODEX_AH_NOTE = "Nur aus dem Auktions-Scan bekannt. Quelle, Drop und Verwendung werden gelernt, sobald jemand es spielt.", + CODEX_VALUE_ITEMS_AH = "Items (Auktionspreise)", + CODEX_VALUE_TOTAL_AH = "Gesamt (Auktion)", + CODEX_SEC_AH = "Auktionshaus", + CODEX_AH_PRICE = "Preis (wie eingestellt)", + CODEX_AH_MIN = "Niedrigster", + CODEX_AH_AVG = "Durchschnitt", + CODEX_AH_QTY = "Angebote", + CODEX_SORTBY_lvl = "Stufe", + CODEX_SORTBY_xpm = "Erfahrung/Minute", + CODEX_SORTBY_dist = "Entfernung", + CODEX_SORTBY_xp = "Erfahrung", + CODEX_SORTBY_money = "Gold-Belohnung", + CODEX_SORTBY_qtime = "Questzeit", + CODEX_SORTBY_price = "Verkaufswert", + CODEX_SORTBY_buy = "Händlerpreis", + CODEX_SORTBY_ah = "Auktionspreis", + CODEX_SORTBY_ilvl = "Itemstufe", + CODEX_SORTBY_quality = "Qualität", + CODEX_SORTBY_chance = "Drop-Chance", + CODEX_SORTBY_gph = "Gold pro Stunde", + CODEX_SORT_DIR_TIP = "Reihenfolge umkehren", + CODEX_FARM = "Farm-Ranking", + CODEX_BACK = "< Zurück", + CODEX_SEC_BESTSRC = "Wo am besten farmen?", + CODEX_BESTSRC_ROW = "%s / Std. | %.0f s pro Kill", + CODEX_CHEAPEST = "günstigster", + CODEX_SORTBY_data = "Datenlage (wenig zuerst)", + CODEX_ROLE_combat = "Kampfkreatur", + CODEX_ROLE_elite = "Elite", + CODEX_ROLE_boss = "Boss", + CODEX_ROLE_classtrainer = "Klassenlehrer", + CODEX_ROLE_proftrainer = "Berufslehrer", + CODEX_SEC_WARES = "Waren (%d)", + CODEX_SEC_TRAINS = "Lehrt (%d)", + CODEX_SEC_FLIGHTS = "Flugziele (%d)", + CODEX_SEC_SOLDBY_LIVE = "Verkauft von (beobachtet)", + CODEX_TITLE_LINE = "Titel: %s", + CODEX_WARE_LIMITED = "begrenzt", + CODEX_WARE_TOKEN = "Tauschware", + CODEX_TRAIN_LEVEL = "Stufe %d", + CODEX_FLIGHT_FROM = "Flugpunkt: %s", + CODEX_ROLES_EVENTS = "Gesehene Ereignisse: %s", + CODEX_ROLES_GOSSIP = "Gesehene Gesprächs-/Fensterarten: %s", + CODEX_ROLES_COUNTS = "Erfasste Rollen: %s", + CODEX_ROLES_LAST = "Zuletzt erfasst: %s (%s %s)", + CODEX_ROLE_vendor = "Händler", + CODEX_ROLE_repair = "Reparatur", + CODEX_ROLE_flight = "Flugmeister", + CODEX_ROLE_innkeeper = "Gastwirt", + CODEX_ROLE_stablemaster = "Stallmeister", + CODEX_ROLE_spirithealer = "Geisterheiler", + CODEX_ROLE_auctioneer = "Auktionator", + CODEX_ROLE_battlemaster = "Schlachtfeldmeister", + CODEX_ROLE_banker = "Bankier", + CODEX_ROLE_rares = "Seltene Kreatur", + CODEX_ROLE_mailbox = "Briefkasten", + CODEX_ROLE_meetingstone = "Versammlungsstein", + CODEX_ROLE_chests = "Truhe", + CODEX_ROLE_herbs = "Kraut", + CODEX_ROLE_mines = "Erz", + CODEX_ROLE_fish = "Fischschwarm", + CODEX_NEAR_HEAD = "Am nächsten (%s):", + CODEX_NEAR_ROW = "%s - %.1f / %.1f", + CODEX_NEAR_NONE = "Auf dieser Karte nichts gefunden (oder keine Vorlagen-Daten).", + CODEX_NEAR_HELP = "Benutzung: /fwcodex near ", + CODEX_SEC_TIME = "Zeit", + CODEX_TTK_AVG = "Kampfdauer (%s)", + CODEX_TTK_XPM = "XP pro Minute (im Kampf)", + CODEX_SEC_TTK_CLASSES = "Kampfdauer nach Klasse", + CODEX_TTK_YOU = "(du)", + CODEX_TTK_ROW = "%.1f s | %d XP/Min", + CODEX_QT_EFF = "Aktive Zeit, auf Quests aufgeteilt", + CODEX_QT_RAW = "Aktive Zeit, ohne Aufteilen", + CODEX_QT_ALL = "Bis zur Abgabe", + CODEX_QT_N = "Messungen", + CODEX_FILTER_STAT = "Wert: %s", + CODEX_FILTER_STATV = "Min / Max", + CODEX_SEC_REWARDOF = "Belohnung von Quests", + CODEX_SEC_QUESTITEM = "Quest-Item für", + CODEX_SEC_PLACES = "Gesehen in", + + CODEX_XP = "Erfahrung", + CODEX_MONEY = "Gold", + CODEX_REP = "Ruf", + CODEX_FIXED = "Belohnung", + CODEX_CHOICE = "Auswahl", + CODEX_CHOSEN = "%dx gewählt", + CODEX_SOURCES = "Quellen", + CODEX_KILLS = "Gelootet: %d", + CODEX_COIN_AVG = "Durchschnittliches Gold: %s", + CODEX_DROP_CHANCE = "%s (%d von %d)", + CODEX_LOW_SAMPLE = "wenig Daten", + CODEX_AVG_QTY = "Ø %.1f", + CODEX_QUEST_ITEM = "Quest-Item", + CODEX_POS = "%s (%.0f, %.0f)", + CODEX_SEC_SPOTS = "Fundorte", + CODEX_SPOT_HITS = "%dx", + CODEX_MAP_SHOW = "Auf der Weltkarte zeigen", + CODEX_MAP_LABEL = "Codex: %s – Klick zum Ausblenden", + CODEX_MAP_NONE = "Noch keine Fundorte erfasst.", + CODEX_MAP_HOVER = "Fundorte in der Nähe", + CODEX_MAP_MORE = "... und %d weitere", + CODEX_TAG_GIVE = "Annahme: %s", + CODEX_TAG_TURN = "Abgabe: %s", + + CODEX_TYPE_monster = "Töten", + CODEX_TYPE_item = "Sammeln", + CODEX_TYPE_object = "Objekt", + CODEX_TYPE_event = "Ereignis", + CODEX_TYPE_log = "Aufgabe", + CODEX_TYPE_other = "Ziel", + + CODEX_KIND_ZONES = "Gebiete", + CODEX_SEC_ZONE_RATE = "Deine Messung", + CODEX_ZONE_RATE_NOTE = "Gemessen beim Spielen mit deinen aktuellen Quest-Einstellungen; eine geänderte Einstellung beginnt eine neue Messung.", + CODEX_ZONE_RATE_LINE = "Stufe %d", + CODEX_ZONE_RATE_LEVEL = ", Level-Up in ca. %d Min.", + CODEX_FILTER_ZONE = "Art: %s", + CODEX_ZONEF_WORLD = "Offene Welt", + CODEX_ZONEF_DUNGEON = "Dungeons", + CODEX_ZONEF_FIT = "Passt zu meiner Stufe", + CODEX_ZONEF_DATA = "Genug Daten", + CODEX_SORTBY_zlvl = "Stufenspanne", + CODEX_SORTBY_cnt = "Kreaturen", + CODEX_ZONE_AVG = "Ø", + CODEX_ZONE_MANUAL = "von Hand gesetzt", + CODEX_ZONE_FEW = "wenig Daten", + CODEX_ZONE_UNITS = "%d Kreaturen", + CODEX_ZONE_OUTLIERS = "%d Ausreißer", + CODEX_ZONE_SPECIAL = "%d Elite/Selten/Boss", + CODEX_ZONE_DUNGEON = "Dungeon", + CODEX_ZONE_WORLD = "Offene Welt", + CODEX_SEC_ZONE_LEVELS = "Stufen", + CODEX_ZONE_TYPICAL = "Typische Spanne", + CODEX_ZONE_COUNT = "Kreaturen (normal)", + CODEX_ZONE_MANUAL_NOTE = "Diese Spanne wurde von Hand gesetzt.", + CODEX_ZONE_FEW_NOTE = "Zu wenige Kreaturen bekannt: die Spanne ist nur eine grobe Schätzung.", + CODEX_ZONE_OUTLIER_LIST = "Ausreißer", + CODEX_ZONE_OUTLIER = "Ausreißer", + CODEX_SEC_ZONE_UNITS = "Kreaturen in diesem Gebiet (%d)", + CODEX_SEC_ZONE_EDIT = "Bearbeiten", + CODEX_SEC_ZONE_QUESTS = "Quests, die in diesem Gebiet beginnen (%d)", + CODEX_SEC_ZONE_FORYOU = "Für deine Stufe (%d, Stufe %d)", + CODEX_SEC_ZONE_FLIGHT = "Flugpunkte (%d)", + CODEX_ZONE_QMIN = "ab Stufe %d", + CODEX_ZONE_EDIT = "Stufenspanne von Hand setzen...", + CODEX_ZONE_EDIT_HINT = "Ersetzt die berechnete Spanne für alle, sobald im Dev-Reiter gesendet wird. Leer lassen oder zurücksetzen, um wieder die berechnete Spanne zu nutzen.", + CODEX_KIND_XP = "Erfahrung", + CODEX_KIND_LEARN = "Lernplan", + CODEX_HINT_MOVE = "Verschieben AN", + CODEX_HINT_MOVE_DONE = "Fertig - speichern", + CODEX_HINT_RESET = "Position zurücksetzen", + CODEX_HINT_DRAG = "Mit der linken Maustaste ziehen", + CODEX_FAVX_HEADER = "Favoriten und Notizen", + CODEX_FAVX_SECTION_NOTE = "Sichere deine Favoriten und Notizen als Text oder übertrage sie auf einen anderen Charakter oder Rechner.", + CODEX_FAVX_OPEN = "Favoriten exportieren / importieren...", + CODEX_FAVX_TITLE = "Favoriten exportieren und importieren", + CODEX_FAVX_NOTE = "Export erzeugt eine Textzeile (Strg+C zum Kopieren). Zum Import eine solche Zeile einfügen (Strg+V) und Import drücken. Vorhandene Favoriten und Notizen bleiben, neue kommen dazu.", + CODEX_FAVX_EXPORT = "Exportieren", + CODEX_FAVX_IMPORT = "Importieren", + CODEX_FAVX_CLOSE = "Schließen", + CODEX_FAVX_EXPORTED = "%d Favoriten exportiert. Mit Strg+C kopieren.", + CODEX_FAVX_IMPORTED = "Importiert: %d neue Favoriten, %d Notizen übernommen, %d ungültige Einträge.", + CODEX_FAVX_BAD = "Das ist keine exportierte Favoriten-Zeile.", + CODEX_SORTBY_zone = "Gebiet (gruppiert)", + CODEX_GROUP_ROW = "%d offen | %d aktiv | %d erledigt", + CODEX_ZONE_UNKNOWN = "Ohne Gebiet", + CODEX_LISTMAX_HEADER = "Länge der Datenbank-Liste", + CODEX_LISTMAX_NOTE = "Wie viele Zeilen die Datenbank-Liste höchstens zeigt. Höhere Werte zeigen mehr Einträge, können das Fenster aber verlangsamen; die Liste übernimmt den Wert beim nächsten Aufbau.", + CODEX_LISTMAX_VALUE = "Bis zu %d Zeilen", + CODEX_HINT_HEADER = "Lern-Hinweise", + CODEX_HINT_NOTE = "Nach einem Stufenaufstieg (außerhalb des Kampfes) oder einer neuen Berufsstufe zeigt ein kurzer Hinweis in der Bildschirmmitte, was du jetzt beim Lehrer lernen kannst. Grundlage sind die Lehrer, die du oder andere besucht haben.", + CODEX_HINT_CLASS_SWITCH = "Hinweis nach Stufenaufstieg: was beim Klassenlehrer lernbar ist", + CODEX_PATHMAP_LABEL = "Wegenetz: %d Wege, %d Fälle, %d Übergänge, %d Lücken", + CODEX_PATHMAP_NONE = "Wegenetz: auf dieser Karte noch nichts aufgezeichnet", + CODEX_PATH_HEADER = "Wegenetz", + CODEX_PATH_NOTE = "Zeichnet auf, wo du zu Fuß gegangen bist (nicht auf Reittier, im Flug, beim Fallen oder Gleiten, nicht in Instanzen), als Raster aus Zellen. Gespeichert werden nur Position und Richtung je Zelle, keine Namen und keine Zeiten. Der Levelplan nutzt das später, um begehbare Wege zu finden.", + CODEX_PATH_SWITCH = "Gegangene Wege aufzeichnen", + CODEX_PATH_CLEANED = "Codex: %d alte fremde Wegzellen (aus früheren Startdaten) entfernt. Empfangene Wege kommen per Sync wieder.", + CODEX_TAXI_HEADER = "Flüge", + CODEX_TAXI_NOTE = "Zeigt beim Flug mit dem Flugmeister ein kleines Fenster mit der verbleibenden Flugzeit.", + CODEX_TAXI_SWITCH = "Flugfenster anzeigen", + CODEX_TAXI_PLAN = "Flüge im Levelplaner berücksichtigen (nur zwischen Flugpunkten, die dieser Charakter kennt)", + CODEX_TAXI_TITLE = "Flug nach %s", + CODEX_TAXI_LEFT = "Noch %s", + CODEX_TAXI_ARRIVING = "Gleich da...", + CODEX_TAXI_UNKNOWN = "%s (Dauer wird erfasst)", + CODEX_TAXI_TIP_TIME = "Flugdauer: %s", + CODEX_TAXI_TIP_NONE = "Flugdauer: unbekannt (wird beim ersten Flug erfasst)", + CODEX_TAXI_TIP_COUNT = "%dx geflogen, insgesamt %s", + CODEX_PATH_STATS = "%d Zellen, %d Verbindungen, %d Fälle, %d Gebietsverbindungen aufgezeichnet", + CODEX_HINT_PROF_SWITCH = "Hinweis bei neuer Berufsstufe: was beim Berufslehrer lernbar ist", + CODEX_HINT_TEST = "Test-Hinweis anzeigen", + CODEX_HINT_CLASS_TITLE = "Stufe %d: Jetzt beim Lehrer lernbar", + CODEX_HINT_PROF_TITLE = "%s %d: Jetzt beim Lehrer lernbar", + CODEX_HINT_MORE = "... und %d weitere", + CODEX_HINT_CLICK = "Klick öffnet den Lernplan", + CODEX_HINT_COST = "Kosten %s", + CODEX_HINT_MISSING = "%s fehlt noch", + CODEX_HINT_DEMO = "Beispiel-Fähigkeit", + CODEX_SORTBY_step = "Stufe (aufsteigend)", + CODEX_LEARN_CLASS = "Klasse", + CODEX_LEARN_SUMSHORT = "gesamt %s", + CODEX_LEARN_EMPTY = "Noch keine Klassenlehrer-Daten. Besuche Klassenlehrer, der Plan füllt sich selbst.", + CODEX_LEARN_TITLE = "Stufe %d", + CODEX_LEARN_SUB = "%d Fähigkeiten | Kosten %s", + CODEX_LEARN_CUM = "Gesamt ab Stufe %d: %s", + CODEX_LEARN_LVUNKNOWN = "Stufe unbekannt", + CODEX_SEC_LEARN_SPELLS = "Fähigkeiten (%d)", + CODEX_SEC_LEARN_TRAINERS = "Lehrer (%d)", + CODEX_LEARN_NOTE = "Aus den besuchten Lehrern zusammengesetzt (eigene und geteilte). Preise: der häufigste gesehene Preis.", + CODEX_ROUTE = "Route planen", + CODEX_ROUTE_RANGE = "Quests in meinem Level-Bereich (+/- 3)", + CODEX_ROUTE_BELOW = "Stufen unterhalb", + CODEX_ROUTE_ABOVE = "Stufen oberhalb", + CODEX_ROUTE_GO = "Route berechnen", + CODEX_ROUTE_NONE = "Keine annehmbaren Quests mit bekannter Position in deinem Gebiet in der Liste.", + CODEX_ROUTE_LABEL = "Quest-Route (%d Stationen)", + CODEX_ROUTE_TIP = "Öffnet die Routenplanung: Stufenbereich wählen und eine Route über die gelisteten, annehmbaren Quests in deinem Gebiet berechnen. Sie folgt dir, solange die Karte offen ist.", + CODEX_FAV_ONLY = "Favoriten", + CODEX_FAV_ADD = "Zu Favoriten", + CODEX_FAV_REMOVE = "Aus Favoriten entfernen", + CODEX_SEC_NOTE = "Meine Notiz", + CODEX_NOTE_HINT = "Kurze Notiz eintippen und Enter drücken...", + CODEX_DEVNOTE_HINT = "Dev-Notiz (nur für dich sichtbar, wird nicht geteilt) ... Enter zum Speichern", + CODEX_XP_TITLE = "Du Stufe %d > Gegner Stufe %d", + CODEX_XP_ELITE = "Elite", + CODEX_XP_RARE = "Selten", + CODEX_XP_BOSS = "Boss", + CODEX_XP_ROW = "Ø %.1f | Formel %d | Abw. %+.1f | n=%d", + CODEX_XP_BONUS_TAG = "Zusatz", + CODEX_XP_RESTED_TAG = "Ausgeruht", + CODEX_XP_WITH_RESTED = "mit Ruhebonus", + CODEX_SEC_XP = "Erfahrung pro Kill", + CODEX_SEC_XPCALC = "Formel (Classic 1-60)", + CODEX_SEC_XPMEAS = "Gemessen", + CODEX_XP_SAMPLES = "Messungen", + CODEX_XP_AVG = "Durchschnitt", + CODEX_XP_MINMAX = "Min / Max", + CODEX_XP_PRED = "Formelwert", + CODEX_XP_DIFF = "Abweichung", + CODEX_XP_COLOR = "Farbe des Gegners", + CODEX_XP_GRAY = "Grau bis Gegnerstufe", + CODEX_XP_ZD = "Nulldifferenz (ZD)", + CODEX_XP_BASE = "Basis (5 x Stufe + 45; bei schwächerem Gegner dessen Stufe)", + CODEX_XP_NOTE = "Messungen mit Ruhe-, Gruppen- oder Raidzusatz stehen getrennt und fließen nicht in die Formelprüfung ein.", + CODEX_XP_UNIT_ROW = "Stufe %d", + CODEX_XP_UNIT_ROW2 = "Stufe %d (Gegner %d)", + CODEX_SORT_PRICE = "Sortierung: Preis", + CODEX_SORT_ILVL = "Sortierung: Itemstufe", + CODEX_SORT_QUALITY = "Sortierung: Qualität", + CODEX_SORT_CHANCE = "Sortierung: Drop-Chance", + CODEX_SORT_GOLD = "Sortierung: Gold/Kill", + CODEX_FILTER = "Filter", + CODEX_FILTER_N = "Filter (%d)", + CODEX_FILTER_RESET = "Zurücksetzen", + CODEX_FILTER_TYPE = "Typ: %s", + CODEX_FILTER_SUB = "Untertyp: %s", + CODEX_FILTER_SLOT = "Platz: %s", + CODEX_FILTER_SRC = "Quelle: %s", + CODEX_FILTER_ZONE = "Gebiet: %s", + CODEX_FILTER_ALL = "alle", + CODEX_FILTER_ILVL = "Itemstufe", + CODEX_FILTER_RLVL = "Benötigt", + CODEX_FILTER_PRICE = "Preis (g)", + CODEX_SRC_UNIT = "Kreaturen", + CODEX_SRC_OBJECT = "Objekte", + CODEX_SRC_REWARD = "Quest-Belohnung", + CODEX_SRC_QUEST = "Quest-Item", + CODEX_QUAL_0 = "Grau", + CODEX_QUAL_1 = "Weiß", + CODEX_QUAL_2 = "Grün", + CODEX_QUAL_3 = "Blau", + CODEX_QUAL_4 = "Episch", + CODEX_QUAL_5 = "Legendär", + CODEX_LOADING_ITEMS = "lade Itemdaten: %d", + CODEX_ILVL_SHORT = "Stufe %d", + CODEX_VALUE_HEAD = "Wert pro gelooteter Leiche", + CODEX_VALUE_COIN = "Gold (tatsächlich gefallen)", + CODEX_VALUE_ITEMS = "Items (Händlerpreis)", + CODEX_VALUE_TOTAL = "Gesamt (Durchschnitt)", + CODEX_VALUE_MISSING = "%d Items noch ohne Preisdaten", + CODEX_VALUE_SHORT = "Ø %s", + CODEX_TEST_OBJ = "Ziele ohne lesbaren Namen: %d", + CODEX_COLOR_red = "rot", + CODEX_COLOR_orange = "orange", + CODEX_COLOR_yellow = "gelb", + CODEX_COLOR_green = "grün", + CODEX_COLOR_gray = "grau", + CODEX_FAMILY = "Familie: %s", + CODEX_TEST_XP = "Erfahrung: %d Messreihen, %d unbekannte Meldungen", + CODEX_YES = "ja", + CODEX_NO = "nein", + CODEX_TEST_API = "API: Beutequelle %s, Ziele %s, Questbelohnung %s, Belohnungs-Hook %s", + CODEX_TEST_COUNTS = "Datenbank: %d Quests, %d Kreaturen, %d Objekte, %d Itemnamen", + CODEX_TEST_STATS = "Diese Sitzung: %d Beutequellen erfasst, %d übersprungene Lesevorgänge", + CODEX_REMOTE_HINT = "Darin %d von %d Leichen von anderen Spielern", + CODEX_REMOTE_ONLY = "Nur Daten von anderen Spielern", + CODEX_SHARE_HEADER = "Datenbank teilen", + CODEX_SHARE_NOTE = "Teilt, was du erfasst hast (Kreaturen, Objekte, Quests, Beute, Erfahrung, gegangene Wege und Gebietsübergänge), mit Gilde, Gruppe, Schlachtzug und einem versteckten Kanal und empfängt dasselbe von anderen. Gesendet werden nur Spieldaten, keine Charakternamen. Empfangene Daten liegen getrennt von deinen und fließen in die angezeigten Zahlen ein.", + CODEX_SHARE_SWITCH = "Datenbank-Daten teilen", + CODEX_SHARE_SWITCH_TIP = "Ein Schalter für alles: Gilde, Gruppe, Schlachtzug und der versteckte Kanal. Standardmäßig aus.", + CODEX_SHARE_STATS = "Gesendet: %d Datensätze | empfangen: %d Datensätze", + CODEX_SHARE_SOURCES = "Gespeichert von %d Spielern: %d Datensätze", + CODEX_SHARE_GLOBAL_OK = "Versteckter Kanal: verbunden", + CODEX_SHARE_GLOBAL_JOINING = "Versteckter Kanal: wird betreten ...", + CODEX_SHARE_GLOBAL_FAILED = "Versteckter Kanal: Beitritt fehlgeschlagen", + CODEX_SHARE_GLOBAL_UNAVAILABLE = "Versteckter Kanal: nicht verfügbar", + CODEX_SHARE_GLOBAL_OFF = "Versteckter Kanal: aus", + CODEX_SHARE_HEARD = "Spieler, von denen in der letzten Stunde etwas kam: %d", + CODEX_SHARE_LOG = "Teilen-Protokoll im Chat zeigen (Fehlersuche)", + CODEX_SHARE_LOG_TIP = "Schreibt jede gesendete (->) und empfangene (<-) Teilen-Nachricht in den Chat. Nach /reload wieder aus.", + CODEX_SHARE_NOW = "Jetzt andere rufen", + CODEX_AVAIL_CLEARED = "Alle Markierungen 'noch nicht möglich' aufgehoben.", + CODEX_AVAIL_UNBLOCKED = "Quest %d darf wieder im Plan stehen.", + CODEX_AVAIL_LIST = "Als 'noch nicht möglich' markiert: %d (/fwblock hebt eine auf, /fwblock alle hebt alle auf)", + CODEX_FOREVER_ON = "Startdaten (ForeverData): %d Datensätze geladen (Version %s). /fwforever aus schaltet sie ab.", + CODEX_FOREVER_OFF = "Startdaten (ForeverData) sind aus. /fwforever an schaltet sie ein.", + CODEX_FOREVER_MISSING = "Startdaten (ForeverData) nicht gefunden: das Addon Freewood-OctoWoW-ForeverData ist nicht installiert.", + CODEX_SHARE_CLEAR = "Empfangene Daten löschen", + CODEX_SHARE_CLEAR_SURE = "Wirklich löschen? Nochmal klicken", + CODEX_SHARE_CLEARED = "Codex: %d empfangene Datensätze gelöscht.", + CODEX_EXPORT_LABEL = "Datenbank", + CODEX_IMPORT_STATS = "%d Datensätze importiert, %d übersprungen", + CODEX_TEST_SHARE = "Teilen: %s, %d eigene Datensätze, %d Spieler mit %d gespeicherten Datensätzen", + CODEX_ON = "an", + CODEX_OFF = "aus", + CODEX_NAME_EDIT = "Name bearbeiten", + CODEX_NAME_IMPORT = "Namen einfügen", + CODEX_NAME_DE = "Deutsch", + CODEX_NAME_EN = "Englisch", + CODEX_NAME_GAME = "Vom Spiel gelernt: %s", + CODEX_NAME_SAVE = "Speichern", + CODEX_NAME_CLEAR = "Namen löschen", + CODEX_NAME_CANCEL = "Abbrechen", + CODEX_NAME_HINT = "Wird mit anderen Spielern geteilt. Dein eigener Eintrag geht immer vor.", + CODEX_NAME_HINT_LOCAL = "Itemnamen bleiben auf diesem Computer.", + CODEX_NAME_LINK = "Nachschlagen: Link anklicken, Strg+C drücken, im Browser einfügen.", + CODEX_NAME_KILLS = "Zuletzt besiegt (zum Übernehmen anklicken):", + CODEX_NAME_NOKILLS = "Noch keine Kampflog-Zeilen gesehen. Besiege etwas und öffne das Fenster danach erneut.", + CODEX_NAME_IMPORT_HELP = "Eine Zeile pro Eintrag: ID, Leerzeichen, Name (auch ID;Name oder ID=Name). Gilt für die aktuelle Liste: %s.", + CODEX_NAME_IMPORT_GO = "Übernehmen", + CODEX_NAME_IMPORT_RESULT = "%d gespeichert, %d übersprungen", + CODEX_NAME_CLOSE = "Schließen", +})