From 9147ef7737c1a305a13ea1b4d672ba3f524ba712 Mon Sep 17 00:00:00 2001 From: Freewood <948+freewood@noreply.octowow.st> Date: Tue, 27 Oct 2026 14:09:21 +0000 Subject: [PATCH] Dateien nach "/" hochladen --- CodexSpots.lua | 322 ++++++++++++ CodexSync.lua | 1262 +++++++++++++++++++++++++++++++++++++++++++++++ CodexTaxi.lua | 505 +++++++++++++++++++ CodexTaxiUI.lua | 149 ++++++ 4 files changed, 2238 insertions(+) create mode 100644 CodexSpots.lua create mode 100644 CodexSync.lua create mode 100644 CodexTaxi.lua create mode 100644 CodexTaxiUI.lua 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..023cff9 --- /dev/null +++ b/CodexSync.lua @@ -0,0 +1,1262 @@ +-- 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())) + local function Letters(by) + local o = {} + for _, l in ipairs({ "u", "o", "q", "i", "x", "t", "p", "z", "g", "h", "f" }) do + if by[l] then o[#o + 1] = l .. "=" .. by[l] end + end + return #o > 0 and table.concat(o, " ") or "-" + end + FW:Print("Eigene Datensaetze nach Art: " .. Letters(Share.OwnInfo())) + for _, r in ipairs(Share.RemoteInfo()) do + FW:Print(string.format("Von %s: Dev %s, normal %d, Wege/Punkte %d | %s", tostring(r.name), r.dev and "ja" or "NEIN", r.n, r.np, Letters(r.by))) + end + if Codex.Spots then + FW:Print(string.format("Ortspunkte sichtbar: %d (davon empfangen: %d), Verbindungen empfangen: %d", + Codex.Spots.Count(), #Share.RemoteRecords("h"), #Share.RemoteRecords("g"))) + end +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