-- 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)