1298 lines
39 KiB
Lua
1298 lines
39 KiB
Lua
-- Freewood-OctoWoW-Beasts: Daten mit anderen Spielern teilen (Addon-Nachrichten)
|
|
--
|
|
-- Gesendet werden nur eigene Sichtungen, Wildtierkunde-Infos und Faehigkeiten gezaehmter Tiere,
|
|
-- keine Namen, keine Notizen. Alles ist standardmaessig AUS und je Kanal einzeln einstellbar:
|
|
-- Gilde, Gruppe, Schlachtzug, Global.
|
|
--
|
|
-- Ablauf (alles "ziehen", nie ungefragt grosse Datenmengen schieben):
|
|
-- H Hallo mit Anzahl und Pruefwert der eigenen Tiere (Kanal)
|
|
-- A Ankuendigung geaenderter Tiere id:stand,... (Kanal)
|
|
-- Q Anfrage nach dem Verzeichnis (Fluestern)
|
|
-- V Verzeichnis id:stand,... in Stuecken (Fluestern)
|
|
-- R Anfrage nach bestimmten Tieren (Fluestern)
|
|
-- B Datensatz eines Tieres in Stuecken (Fluestern)
|
|
-- Zusaetzlich gibt es eine Personenliste (Battle.net-Freunde oder Charakternamen). Daten fliessen
|
|
-- nur, wenn beide sich gegenseitig eingetragen haben.
|
|
-- Empfangene Datensaetze laufen durch dieselbe Pruefung wie ein Import und liegen getrennt von
|
|
-- den eigenen Daten.
|
|
|
|
local ADDON, NS = ...
|
|
|
|
local FW = _G.Freewood
|
|
local Beasts = NS.Beasts
|
|
if not FW or not Beasts then
|
|
return
|
|
end
|
|
|
|
local Sync = { started = false }
|
|
NS.Sync = Sync
|
|
Beasts.Sync = Sync
|
|
|
|
local PREFIX = "FWBeasts1"
|
|
local GLOBAL_CHANNEL = "FreewoodBeasts"
|
|
local MAXLEN = 230 -- Nutzlast je Nachricht
|
|
local SEND_INTERVAL = 0.4 -- Sekunden zwischen zwei Nachrichten
|
|
local GLOBAL_INTERVAL = 1.5 -- Global noch sparsamer
|
|
local MAX_QUEUE = 400
|
|
local MAX_CHUNKS = 40 -- Stuecke je Datensatz
|
|
local MAX_BUFFERS = 6
|
|
local SESSION_TTL = 600 -- Sekunden, so lange werden Antworten eines Anfrage-Partners angenommen
|
|
local SERVE_COOLDOWN = 300 -- ein Partner darf unser Verzeichnis alle 5 Minuten holen
|
|
local MAX_SERVE = 40 -- Tiere je Partner und Zeitraum
|
|
local MAX_REQUEST = 40 -- Tiere je Abgleich
|
|
local MAX_SESSIONS_HOUR = 6 -- selbst gestartete Abgleiche je Stunde
|
|
local MAX_RECEIVED_HOUR = 200 -- empfangene Datensaetze je Partner und Stunde
|
|
local ANNOUNCE_EVERY = 60
|
|
local ANNOUNCE_MIN = 300 -- ein Tier hoechstens alle 5 Minuten ankuendigen
|
|
local KINDS = { "guild", "party", "raid", "global" }
|
|
|
|
local state = {
|
|
queue = {},
|
|
peers = {}, -- [name] = { kind, t }
|
|
sessions = {}, -- [name] = { t, digest = {...}, total }
|
|
served = {}, -- [name] = { t, count }
|
|
buffers = {}, -- [name .. id] = { t, n, parts }
|
|
received = {}, -- [name] = { t, count }
|
|
pending = {}, -- angekuendigte Tiere: [id] = true
|
|
announced = {}, -- [id] = zeitpunkt
|
|
sessionLog = {}, -- Zeitpunkte gestarteter Abgleiche
|
|
lastGlobalSend = 0,
|
|
routes = {}, -- [key] = { bn = gameAccountID, t } (Battle.net-Wege)
|
|
rawNames = {}, -- [key] = Absendername wie vom Spiel geliefert (Fluester-Ziel)
|
|
blind = {}, -- [name] = Zeitpunkt des letzten Hallo an einen Charakter
|
|
status = { global = "off" },
|
|
}
|
|
Sync.state = state
|
|
|
|
local function Now()
|
|
return (GetTime and GetTime()) or time()
|
|
end
|
|
|
|
local function Settings()
|
|
local s = Beasts.db.settings
|
|
s.share = s.share or { guild = false, party = false, raid = false, global = false }
|
|
return s.share
|
|
end
|
|
|
|
local function Store()
|
|
Beasts.db.sync = Beasts.db.sync or { have = {}, sent = 0, received = 0 }
|
|
Beasts.db.sync.have = Beasts.db.sync.have or {}
|
|
return Beasts.db.sync
|
|
end
|
|
|
|
local function People()
|
|
local s = Beasts.db.settings
|
|
s.people = s.people or {}
|
|
return s.people
|
|
end
|
|
|
|
function Sync:IsEnabled(kind)
|
|
return Settings()[kind] == true
|
|
end
|
|
|
|
function Sync:AnyEnabled()
|
|
for _, kind in ipairs(KINDS) do
|
|
if Settings()[kind] then
|
|
return true
|
|
end
|
|
end
|
|
return #People() > 0
|
|
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
|
|
|
|
-- Schluessel eines Absenders: kleingeschrieben, ohne Serveranhang wenn es der eigene ist
|
|
local function KeyOf(sender)
|
|
local c = Clean(sender)
|
|
if not c then
|
|
return nil
|
|
end
|
|
if c:sub(1, 3) == "bn:" then
|
|
return c:lower()
|
|
end
|
|
return c:lower()
|
|
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
|
|
|
|
-- Eigener Name: Spielfunktionen liefern bei Forever nur den Vornamen; der volle Name (mit Nachnamen),
|
|
-- unter dem uns die anderen sehen, wird ueber eine Probe-Nachricht "E|<Nummer>" gelernt
|
|
local learnedSelf = {}
|
|
local SESSION_NONCE = string.format("%06d", math.random(0, 999999)) .. string.format("%03d", math.random(0, 999))
|
|
|
|
local function IsSelf(sender)
|
|
if type(sender) ~= "string" then
|
|
return false
|
|
end
|
|
local c = NameCore(sender)
|
|
if c == "" then
|
|
return false
|
|
end
|
|
if learnedSelf[c] then
|
|
return true
|
|
end
|
|
local me = UnitName and UnitName("player")
|
|
return me ~= nil and NameCore(me) == c
|
|
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
|
|
|
|
local function DecodeRecord(body)
|
|
local E = GetEncoder()
|
|
if not E or type(body) ~= "string" or body:find("[^%w%+/=]") then
|
|
return nil
|
|
end
|
|
local ok, packed = pcall(E.DecodeBase64, body)
|
|
if not ok or type(packed) ~= "string" then
|
|
return nil
|
|
end
|
|
local ok2, cbor = pcall(E.DecompressString, packed)
|
|
if not ok2 or type(cbor) ~= "string" then
|
|
return nil
|
|
end
|
|
local ok3, rec = pcall(E.DeserializeCBOR, cbor)
|
|
if not ok3 or type(rec) ~= "table" then
|
|
return nil
|
|
end
|
|
if FW.Export and FW.Export.CheckStructure and not FW.Export.CheckStructure(rec) then
|
|
return nil
|
|
end
|
|
return rec
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Senden mit Warteschlange
|
|
------------------------------------------------------------------------
|
|
|
|
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)
|
|
if ok and res == 3 then
|
|
return false, true -- gebremst: Nachricht behalten
|
|
end
|
|
return ok
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function Enqueue(item)
|
|
local q = state.queue
|
|
if #q >= MAX_QUEUE then
|
|
-- aelteste Rundrufe verwerfen, Datensaetze bleiben
|
|
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
|
|
|
|
-- Eine Nachricht aus der Schlange senden (vom Zeitgeber aufgerufen)
|
|
function Sync:Pump()
|
|
local q = state.queue
|
|
if #q == 0 then
|
|
return false
|
|
end
|
|
local now = Now()
|
|
for i = 1, #q do
|
|
local item = q[i]
|
|
if item.dist == "CHANNEL" and now - state.lastGlobalSend < GLOBAL_INTERVAL then
|
|
-- noch nicht dran, naechste Nachricht versuchen
|
|
else
|
|
table.remove(q, i)
|
|
if item.dist == "CHANNEL" then
|
|
state.lastGlobalSend = now
|
|
end
|
|
if item.bn then
|
|
Sync.SendBN(item.bn, item.text)
|
|
else
|
|
local _, throttled = Raw(item.dist, item.target, item.text)
|
|
if throttled then
|
|
item.tries = (item.tries or 0) + 1
|
|
if item.tries <= 12 then table.insert(q, 1, item) end
|
|
state.lastGlobalSend = now + 3
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Battle.net-Nachricht an ein Spielkonto eines Freundes
|
|
function Sync.SendBN(gameAccountID, text)
|
|
local fn = (_G.C_BattleNet and _G.C_BattleNet.SendGameData) or _G.BNSendGameData
|
|
if type(fn) ~= "function" then
|
|
return false
|
|
end
|
|
return pcall(fn, gameAccountID, PREFIX, text)
|
|
end
|
|
|
|
function Sync:BNAvailable()
|
|
return type((_G.C_BattleNet and _G.C_BattleNet.SendGameData) or _G.BNSendGameData) == "function"
|
|
and type(_G.C_BattleNet and _G.C_BattleNet.GetFriendAccountInfo) == "function"
|
|
end
|
|
|
|
-- Antwort an einen Absender (Schluessel): Battle.net-Weg oder Fluestern
|
|
local function Whisper(key, text)
|
|
if key:sub(1, 3) == "bn:" then
|
|
local route = state.routes[key]
|
|
if route and route.bn then
|
|
return Enqueue({ bn = route.bn, text = text })
|
|
end
|
|
return false
|
|
end
|
|
-- OctoWoW: kein Fluestern. Antwort ueber denselben Kanal, mit dem Namen des Empfaengers in der Nachricht
|
|
-- (andere ignorieren sie).
|
|
local peer = state.peers[key]
|
|
local kind = (peer and peer.kind) or "global"
|
|
if kind == "people" then kind = "global" 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
|
|
|
|
-- Verteilung fuer eine Art: Rueckgabe dist, target oder nil
|
|
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
|
|
|
|
-- Rundruf in alle eingeschalteten Kanaele (ohne Doppelsendung ueber dieselbe Verteilung)
|
|
local function Broadcast(text)
|
|
local used = {}
|
|
local sent = 0
|
|
for _, kind in ipairs(KINDS) do
|
|
if Settings()[kind] then
|
|
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
|
|
end
|
|
return sent
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Personenliste (Battle.net-Freunde und Charakternamen)
|
|
------------------------------------------------------------------------
|
|
|
|
function Sync:GetPeople()
|
|
return People()
|
|
end
|
|
|
|
local function PersonKey(p)
|
|
if p.kind == "bnet" then
|
|
return "bn:" .. (p.tag or ""):lower()
|
|
end
|
|
return (Clean(p.name or "") or ""):lower()
|
|
end
|
|
|
|
function Sync:AddPerson(kind, value, label)
|
|
value = type(value) == "string" and value:gsub("^%s+", ""):gsub("%s+$", "") or nil
|
|
if not value or value == "" or #value > 64 or value:find("[%c|]") then
|
|
return false, "invalid"
|
|
end
|
|
if kind ~= "bnet" and kind ~= "char" then
|
|
return false, "invalid"
|
|
end
|
|
local p = { kind = kind, label = type(label) == "string" and label:sub(1, 48) or nil }
|
|
if kind == "bnet" then
|
|
p.tag = value
|
|
else
|
|
p.name = value
|
|
end
|
|
local key = PersonKey(p)
|
|
for _, q in ipairs(People()) do
|
|
if PersonKey(q) == key then
|
|
return false, "exists"
|
|
end
|
|
end
|
|
if #People() >= 50 then
|
|
return false, "full"
|
|
end
|
|
People()[#People() + 1] = p
|
|
FW:Fire("BEASTS_SYNC_CHANGED")
|
|
C_Timer.After(2 + math.random() * 3, function()
|
|
Sync:SendHelloPeople()
|
|
end)
|
|
return true
|
|
end
|
|
|
|
function Sync:RemovePerson(index)
|
|
local list = People()
|
|
if list[index] then
|
|
table.remove(list, index)
|
|
FW:Fire("BEASTS_SYNC_CHANGED")
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Ist dieser Absender (Schluessel) auf meiner Liste?
|
|
function Sync:IsListed(key)
|
|
if type(key) ~= "string" then
|
|
return false
|
|
end
|
|
for _, p in ipairs(People()) do
|
|
if PersonKey(p) == key then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Freunde bei Battle.net: { {tag, name, online, gameAccountID, character} }
|
|
function Sync:BNFriends()
|
|
local out = {}
|
|
local api = _G.C_BattleNet
|
|
if not api or type(api.GetFriendAccountInfo) ~= "function" then
|
|
return out
|
|
end
|
|
local num = _G.BNGetNumFriends and select(1, _G.BNGetNumFriends()) or 0
|
|
for i = 1, num do
|
|
local ok, info = pcall(api.GetFriendAccountInfo, i)
|
|
if ok and type(info) == "table" then
|
|
local g = info.gameAccountInfo or {}
|
|
out[#out + 1] = {
|
|
tag = info.battleTag,
|
|
name = info.accountName,
|
|
online = (g.isOnline and g.clientProgram == "WoW") and true or false,
|
|
gameAccountID = g.gameAccountID,
|
|
character = g.characterName,
|
|
}
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- Battle.net-Schluessel zu einer Spielkonto-Nummer (Absender einer Nachricht)
|
|
function Sync:BNKeyForGameAccount(gameAccountID)
|
|
for _, f in ipairs(self:BNFriends()) do
|
|
if f.gameAccountID == gameAccountID and f.tag then
|
|
return "bn:" .. f.tag:lower(), f
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Erreichbare Personen aus der Liste: Liste von Schluesseln (Battle.net nur wenn gerade in WoW)
|
|
function Sync:ReachablePeople(includeBlind)
|
|
local keys = {}
|
|
local friends
|
|
for _, p in ipairs(People()) do
|
|
local key = PersonKey(p)
|
|
if p.kind == "bnet" then
|
|
friends = friends or self:BNFriends()
|
|
for _, f in ipairs(friends) do
|
|
if f.tag and ("bn:" .. f.tag:lower()) == key and f.online and f.gameAccountID then
|
|
state.routes[key] = { bn = f.gameAccountID, t = Now() }
|
|
keys[#keys + 1] = key
|
|
break
|
|
end
|
|
end
|
|
else
|
|
local peer = state.peers[key]
|
|
local heard = peer and Now() - peer.t < 1200
|
|
if heard then
|
|
keys[#keys + 1] = key
|
|
elseif includeBlind and Now() - (state.blind[key] or 0) > 600 then
|
|
state.blind[key] = Now()
|
|
state.rawNames[key] = state.rawNames[key] or p.name
|
|
keys[#keys + 1] = key
|
|
end
|
|
end
|
|
end
|
|
return keys
|
|
end
|
|
|
|
-- Fehlermeldung "Spieler nicht gefunden" fuer Personen unserer Liste ausblenden
|
|
local filterInstalled = false
|
|
local function InstallFilter()
|
|
if filterInstalled or type(ChatFrame_AddMessageEventFilter) ~= "function" then
|
|
return
|
|
end
|
|
filterInstalled = true
|
|
local pattern
|
|
local fmt = _G.ERR_CHAT_PLAYER_NOT_FOUND_S
|
|
if type(fmt) == "string" then
|
|
pattern = "^" .. fmt:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"):gsub("%%%%s", "(.+)") .. "$"
|
|
end
|
|
ChatFrame_AddMessageEventFilter("CHAT_MSG_SYSTEM", function(_, _, msg)
|
|
if pattern and type(msg) == "string" then
|
|
local who = msg:match(pattern)
|
|
if who and Sync:IsListed(KeyOf(who:gsub("^'", ""):gsub("'$", "")) or "") then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end)
|
|
end
|
|
|
|
function Sync:SendHelloPeople()
|
|
if #People() == 0 then
|
|
return 0
|
|
end
|
|
local n, h = Beasts:ShareSummary()
|
|
if n == 0 then
|
|
return 0
|
|
end
|
|
InstallFilter()
|
|
local sent = 0
|
|
for _, key in ipairs(self:ReachablePeople(true)) do
|
|
if Whisper(key, string.format("H|%d|%d", h, n)) then
|
|
sent = sent + 1
|
|
end
|
|
end
|
|
return sent
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Hallo und Ankuendigungen
|
|
------------------------------------------------------------------------
|
|
|
|
function Sync:SendHello()
|
|
if not self:AnyEnabled() then
|
|
return false
|
|
end
|
|
local n, h = Beasts:ShareSummary()
|
|
if n == 0 then
|
|
return false
|
|
end
|
|
if next(learnedSelf) == nil then
|
|
Broadcast("E|" .. SESSION_NONCE)
|
|
end
|
|
local sent = Broadcast(string.format("H|%d|%d", h, n))
|
|
sent = sent + self:SendHelloPeople()
|
|
return sent > 0
|
|
end
|
|
|
|
local function Version(id)
|
|
local e = Beasts:ShareEntry(id)
|
|
return e and (e.updated or 0) or 0
|
|
end
|
|
|
|
local function Have(id, upd)
|
|
local h = Store().have[id]
|
|
return h and h[upd] == true
|
|
end
|
|
|
|
local function MarkHave(id, upd)
|
|
local have = Store().have
|
|
local h = have[id]
|
|
if not h then
|
|
h = { order = {} }
|
|
have[id] = h
|
|
end
|
|
h.order = h.order or {}
|
|
if not h[upd] then
|
|
h[upd] = true
|
|
h.order[#h.order + 1] = upd
|
|
while #h.order > 20 do
|
|
h[table.remove(h.order, 1)] = nil
|
|
end
|
|
end
|
|
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(id)
|
|
if not self:AnyEnabled() or type(id) ~= "number" then
|
|
return
|
|
end
|
|
local e = Beasts:ShareEntry(id)
|
|
if e and (e.seen or e.lored or e.tamed) then
|
|
state.pending[id] = true
|
|
end
|
|
end
|
|
|
|
function Sync:FlushAnnounce()
|
|
if not self:AnyEnabled() then
|
|
state.pending = {}
|
|
return 0
|
|
end
|
|
local now = Now()
|
|
local ids = {}
|
|
for id in pairs(state.pending) do
|
|
if now - (state.announced[id] or 0) >= ANNOUNCE_MIN then
|
|
ids[#ids + 1] = id
|
|
end
|
|
end
|
|
table.sort(ids)
|
|
local parts, count = {}, 0
|
|
local function flush()
|
|
if #parts > 0 then
|
|
local text = "A|" .. table.concat(parts, ",")
|
|
Broadcast(text)
|
|
for _, key in ipairs(Sync:ReachablePeople(false)) do
|
|
Whisper(key, text)
|
|
end
|
|
parts = {}
|
|
end
|
|
end
|
|
for _, id in ipairs(ids) do
|
|
state.pending[id] = nil
|
|
state.announced[id] = now
|
|
parts[#parts + 1] = string.format("%d:%s", id, ToB36(Version(id)))
|
|
count = count + 1
|
|
if #parts >= 12 then
|
|
flush()
|
|
end
|
|
end
|
|
flush()
|
|
return count
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Verzeichnis und Datensaetze
|
|
------------------------------------------------------------------------
|
|
|
|
-- Eintraege "id:stand" in Nachrichten verpacken
|
|
local function PackList(prefix, entries)
|
|
local msgs, current = {}, {}
|
|
local len = 0
|
|
for _, e in ipairs(entries) do
|
|
local add = #e + 1
|
|
if len + add > MAXLEN - 36 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 id, upd in body:gmatch("(%d+):([0-9a-z]+)") do
|
|
local nid, nupd = tonumber(id), FromB36(upd)
|
|
if nid and nupd and nid >= 1 and nid <= 10000000 and #out < 400 then
|
|
out[#out + 1] = { id = nid, upd = nupd }
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
function Sync:SendDigest(name)
|
|
local entries = {}
|
|
for id, e in pairs(Beasts:ShareIds()) do
|
|
entries[#entries + 1] = { id = id, upd = e.updated or 0 }
|
|
end
|
|
table.sort(entries, function(a, b)
|
|
return a.upd > b.upd
|
|
end)
|
|
local flat = {}
|
|
for i, e in ipairs(entries) do
|
|
if i > 400 then
|
|
break
|
|
end
|
|
flat[#flat + 1] = string.format("%d:%s", e.id, ToB36(e.upd))
|
|
end
|
|
for _, msg in ipairs(PackList("V", flat)) do
|
|
Whisper(name, msg)
|
|
end
|
|
return #flat
|
|
end
|
|
|
|
-- Welche Tiere aus einer Liste fehlen uns (neuer Stand, den wir noch nicht kennen)?
|
|
local function Missing(entries)
|
|
local need = {}
|
|
for _, e in ipairs(entries) do
|
|
if e.upd > 0 and not Have(e.id, e.upd) then
|
|
need[#need + 1] = e
|
|
end
|
|
end
|
|
table.sort(need, function(a, b)
|
|
return a.upd > b.upd
|
|
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("%d:%s", e.id, ToB36(e.upd))
|
|
end
|
|
if #ids == 0 then
|
|
return 0
|
|
end
|
|
for _, msg in ipairs(PackList("R", ids)) do
|
|
Whisper(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 or now - s.t > SERVE_COOLDOWN then
|
|
s = { t = now, count = 0 }
|
|
state.served[name] = s
|
|
end
|
|
local sent = 0
|
|
for _, e in ipairs(entries) do
|
|
if s.count >= MAX_SERVE then
|
|
break
|
|
end
|
|
local rec = Beasts:ShareRecord(e.id)
|
|
if rec then
|
|
local body = EncodeRecord(rec)
|
|
local upd = Version(e.id)
|
|
if body then
|
|
local PIECE = MAXLEN - 46 -- Platz fuer Kopf und Empfaengername
|
|
local n = math.ceil(#body / PIECE)
|
|
if n >= 1 and n <= MAX_CHUNKS then
|
|
for i = 1, n do
|
|
local piece = body:sub((i - 1) * PIECE + 1, i * PIECE)
|
|
Whisper(name, string.format("B|%d|%s|%d/%d|%s", e.id, ToB36(upd), i, n, piece))
|
|
end
|
|
s.count = s.count + 1
|
|
sent = sent + 1
|
|
Store().sent = (Store().sent or 0) + 1
|
|
end
|
|
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, id, upd, i, n, piece)
|
|
local sess = state.sessions[name]
|
|
if not sess or Now() - sess.t > SESSION_TTL then
|
|
return false
|
|
end
|
|
if not id 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 key = name .. ":" .. id
|
|
local buf = state.buffers[key]
|
|
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, id = id, upd = upd }
|
|
state.buffers[key] = buf
|
|
end
|
|
if buf.n ~= n or buf.id ~= id then
|
|
state.buffers[key] = 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[key] = nil
|
|
local ok, r = ReceivedAllowed(name)
|
|
if not ok then
|
|
return false
|
|
end
|
|
r.count = r.count + 1
|
|
local rec = DecodeRecord(table.concat(buf.parts))
|
|
if not rec or rec.id ~= id then
|
|
return false
|
|
end
|
|
MarkHave(id, upd)
|
|
Beasts:ImportData({ list = { rec } }, { merge = true })
|
|
local store = Store()
|
|
store.received = (store.received or 0) + 1
|
|
store.lastReceived = time()
|
|
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 (Settings().party and "party") or (Settings().raid and "raid") or "party"
|
|
elseif channel == "CHANNEL" then
|
|
return "global"
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- text: "T|..."; channel: Verteilung laut Ereignis; sender: Absender
|
|
function Sync:HandleMessage(channel, sender, text)
|
|
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 then
|
|
return false
|
|
end
|
|
if channel ~= "BN" then
|
|
state.rawNames[name] = sender
|
|
end
|
|
if channel == "CHANNEL" then
|
|
local t0 = text:sub(1, 1)
|
|
if t0 == "Q" or t0 == "V" or t0 == "R" or t0 == "B" then
|
|
-- an uns persoenlich gerichtet? Format: "<Art>|<Empfaenger>|<Rest>"
|
|
local target, rest = text:sub(3):match("^([^|]+)|(.*)$")
|
|
if not target or not IsSelf(target) then
|
|
return false
|
|
end
|
|
text = t0 .. "|" .. rest
|
|
channel = "WHISPER"
|
|
-- wer uns ueber den eingeschalteten Kanal anspricht, darf auch bedient werden
|
|
if (t0 == "Q" or t0 == "R") and Settings().global then
|
|
local pr = state.peers[name]
|
|
if not pr then
|
|
state.peers[name] = { kind = "global", t = Now() }
|
|
elseif pr.kind == "global" then
|
|
pr.t = Now()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local t = text:sub(1, 1)
|
|
local body = text:sub(3)
|
|
local kind = KindOf(channel)
|
|
local listed = (channel == "WHISPER") and self:IsListed(name)
|
|
if not kind and listed and (t == "H" or t == "A") then
|
|
kind = "people"
|
|
end
|
|
|
|
if kind then
|
|
-- Rundrufe nur aus eingeschalteten Kanaelen bzw. von Personen aus der Liste
|
|
if kind == "people" then
|
|
if not listed then
|
|
return false
|
|
end
|
|
elseif not Settings()[kind] then
|
|
return false
|
|
end
|
|
if t == "H" then
|
|
local h, n = body:match("^(%d+)|(%d+)$")
|
|
h, n = tonumber(h), tonumber(n)
|
|
if not h or not n or n < 1 then
|
|
return false
|
|
end
|
|
local mine = select(2, Beasts:ShareSummary())
|
|
local peer = state.peers[name] or {}
|
|
state.peers[name] = { kind = kind, t = Now(), hash = h, count = n, queried = peer.queried }
|
|
if h ~= mine and not (peer.queried and Now() - peer.queried < 600) 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 }
|
|
Whisper(name, "Q|1")
|
|
end
|
|
end
|
|
return true
|
|
elseif t == "A" then
|
|
local entries = ParseEntries(body)
|
|
state.peers[name] = { kind = kind, t = Now(), hash = (state.peers[name] or {}).hash, queried = (state.peers[name] or {}).queried }
|
|
local need = Missing(entries)
|
|
if #need > 0 and #need <= 12 then
|
|
self:SendRequest(name, need)
|
|
end
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
if channel ~= "WHISPER" then
|
|
return false
|
|
end
|
|
local peer = state.peers[name]
|
|
if t == "Q" then
|
|
-- nur Partner bedienen, die uns ueber einen eingeschalteten Kanal gerufen haben
|
|
if not listed and (not peer or Now() - peer.t > 900 or not Settings()[peer.kind]) then
|
|
return false
|
|
end
|
|
local s = state.served[name]
|
|
if s and Now() - s.t < SERVE_COOLDOWN and s.digest then
|
|
return false
|
|
end
|
|
state.served[name] = { t = Now(), count = 0, digest = true }
|
|
self:SendDigest(name)
|
|
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 < 400 then
|
|
sess.digest[#sess.digest + 1] = e
|
|
end
|
|
end
|
|
sess.got = (sess.got or 0) + 1
|
|
if sess.got >= n then
|
|
local need = Missing(sess.digest)
|
|
sess.digest = nil
|
|
self:SendRequest(name, need)
|
|
end
|
|
return true
|
|
elseif t == "R" then
|
|
if not listed and (not peer or Now() - peer.t > 900 or not Settings()[peer.kind]) then
|
|
return false
|
|
end
|
|
local _, _, list = body:match("^(%d+)/(%d+)|(.*)$")
|
|
local entries = ParseEntries(list)
|
|
-- nur ausliefern, was wir wirklich haben
|
|
local valid = {}
|
|
for _, e in ipairs(entries) do
|
|
if Beasts:ShareEntry(e.id) then
|
|
valid[#valid + 1] = e
|
|
end
|
|
end
|
|
self:ServeRecords(name, valid)
|
|
return true
|
|
elseif t == "B" then
|
|
local id, upd, i, n, piece = body:match("^(%d+)|([0-9a-z]+)|(%d+)/(%d+)|(.+)$")
|
|
return self:ReceiveChunk(name, tonumber(id), FromB36(upd), tonumber(i), tonumber(n), piece)
|
|
end
|
|
return false
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Global-Kanal
|
|
------------------------------------------------------------------------
|
|
|
|
function Sync:JoinGlobal()
|
|
if not Settings().global 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
|
|
-- aus allen Chatfenstern ausblenden, damit nichts im Chat auftaucht
|
|
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:AnyEnabled() then
|
|
Sync:SendHello()
|
|
end
|
|
FW:Fire("BEASTS_SYNC_CHANGED")
|
|
end)
|
|
end
|
|
|
|
function Sync:LeaveGlobal()
|
|
if type(LeaveChannelByName) == "function" then
|
|
pcall(LeaveChannelByName, GLOBAL_CHANNEL)
|
|
end
|
|
state.status.global = "off"
|
|
end
|
|
|
|
function Sync:SetShare(kind, on)
|
|
local s = Settings()
|
|
if s[kind] == nil then
|
|
return
|
|
end
|
|
s[kind] = on and true or false
|
|
if kind == "global" then
|
|
if on then
|
|
self:JoinGlobal()
|
|
else
|
|
self:LeaveGlobal()
|
|
end
|
|
end
|
|
if on then
|
|
C_Timer.After(2 + math.random() * 3, function()
|
|
Sync:SendHello()
|
|
end)
|
|
end
|
|
FW:Fire("BEASTS_SYNC_CHANGED")
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Start / Stopp und Zeitgeber
|
|
------------------------------------------------------------------------
|
|
|
|
local function Tick()
|
|
if not Sync.started then
|
|
return
|
|
end
|
|
Sync:Pump()
|
|
-- abgelaufene Zwischenspeicher entfernen
|
|
local now = Now()
|
|
for key, buf in pairs(state.buffers) do
|
|
if now - buf.t > 60 then
|
|
state.buffers[key] = nil
|
|
end
|
|
end
|
|
C_Timer.After(SEND_INTERVAL, Tick)
|
|
end
|
|
|
|
local function AnnounceLoop()
|
|
if not Sync.started then
|
|
return
|
|
end
|
|
Sync:FlushAnnounce()
|
|
C_Timer.After(ANNOUNCE_EVERY, AnnounceLoop)
|
|
end
|
|
|
|
local function HelloLoop()
|
|
if not Sync.started then
|
|
return
|
|
end
|
|
Sync:SendHello()
|
|
C_Timer.After(2700, HelloLoop) -- alle 45 Minuten
|
|
end
|
|
|
|
function Sync:OnAddonMessage(prefix, text, channel, sender)
|
|
if prefix ~= PREFIX then
|
|
return
|
|
end
|
|
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 ~= "" then
|
|
learnedSelf[c] = true
|
|
end
|
|
end
|
|
return
|
|
end
|
|
if type(text) == "string" and text:sub(1, 2) == "G|" then
|
|
self:HandleBye(sender)
|
|
return
|
|
end
|
|
self:HandleMessage(channel, sender, text)
|
|
end
|
|
|
|
-- Ein Spieler meldet sich ab: ihm nichts mehr senden, Warteschlange fuer ihn leeren
|
|
function Sync:HandleBye(sender)
|
|
local name = KeyOf(sender)
|
|
if not name or IsSelf(sender) then return end
|
|
state.peers[name] = nil
|
|
state.sessions[name] = nil
|
|
state.served[name] = nil
|
|
local q = state.queue
|
|
for i = #q, 1, -1 do
|
|
local item = q[i]
|
|
if not item.bn then
|
|
local who = tostring(item.text or ""):match("^%a|([^|]+)|")
|
|
if who and KeyOf(who) == name then table.remove(q, i) end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Beim Ausloggen/Beenden "Tschuess" in den Kanal (an der Warteschlange vorbei)
|
|
function Sync:SayBye()
|
|
if not self.started or not self:AnyEnabled() then return end
|
|
local dist, target = self:Distribution("global")
|
|
if dist then Raw(dist, target, "G|") end
|
|
state.queue = {}
|
|
end
|
|
|
|
local byeHooked = false
|
|
local function InstallByeHooks()
|
|
if byeHooked then return end
|
|
byeHooked = true
|
|
if _G.hooksecurefunc then
|
|
for _, fn in ipairs({ "Logout", "Quit" }) do
|
|
if type(_G[fn]) == "function" then pcall(hooksecurefunc, fn, function() Sync:SayBye() end) end
|
|
end
|
|
end
|
|
for _, ev in ipairs({ "PLAYER_QUITING", "PLAYER_CAMPING", "PLAYER_LOGOUT" }) do
|
|
pcall(FW.RegisterEvent, FW, ev, function() Sync:SayBye() end, Sync)
|
|
end
|
|
pcall(FW.RegisterEvent, FW, "CANCEL_LOGOUT", function()
|
|
if Sync.started then C_Timer.After(2, function() Sync:SendHello() end) end
|
|
end, Sync)
|
|
end
|
|
|
|
-- Nachricht eines Battle.net-Freundes
|
|
function Sync:OnBNMessage(prefix, text, _, senderID)
|
|
if prefix ~= PREFIX then
|
|
return
|
|
end
|
|
local key = self:BNKeyForGameAccount(senderID)
|
|
if not key or not self:IsListed(key) then
|
|
return
|
|
end
|
|
state.routes[key] = { bn = senderID, t = Now() }
|
|
self:HandleMessage("WHISPER", key, text)
|
|
end
|
|
|
|
local friendHelloAt = 0
|
|
function Sync:OnFriendOnline()
|
|
if #People() == 0 or Now() - friendHelloAt < 60 then
|
|
return
|
|
end
|
|
friendHelloAt = Now()
|
|
C_Timer.After(5 + math.random() * 5, function()
|
|
Sync:SendHelloPeople()
|
|
end)
|
|
end
|
|
|
|
local groupHelloAt = 0
|
|
function Sync:OnGroupChanged()
|
|
if not (Settings().party or Settings().raid) then
|
|
return
|
|
end
|
|
if Now() - groupHelloAt < 120 then
|
|
return
|
|
end
|
|
groupHelloAt = Now()
|
|
C_Timer.After(8, function()
|
|
Sync:SendHello()
|
|
end)
|
|
end
|
|
|
|
function Sync:Start()
|
|
if self.started then
|
|
return
|
|
end
|
|
self.started = true
|
|
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:RegisterEvent("BN_CHAT_MSG_ADDON", self.OnBNMessage, self)
|
|
FW:RegisterEvent("BN_FRIEND_ACCOUNT_ONLINE", self.OnFriendOnline, self)
|
|
InstallByeHooks()
|
|
FW:On("BEASTS_UPDATED", function(_, id)
|
|
if id then
|
|
Sync:QueueAnnounce(id)
|
|
end
|
|
end, self)
|
|
C_Timer.After(SEND_INTERVAL, Tick)
|
|
C_Timer.After(ANNOUNCE_EVERY, AnnounceLoop)
|
|
-- erstes Hallo nach dem Einloggen, etwas verteilt, damit nicht alle gleichzeitig rufen
|
|
C_Timer.After(20 + math.random() * 30, function()
|
|
if Sync.started then
|
|
Sync:JoinGlobal()
|
|
HelloLoop()
|
|
end
|
|
end)
|
|
end
|
|
|
|
function Sync:Stop()
|
|
self.started = false
|
|
FW:UnregisterEvent("CHAT_MSG_ADDON", self)
|
|
FW:UnregisterEvent("GROUP_ROSTER_UPDATE", self)
|
|
FW:UnregisterEvent("BN_CHAT_MSG_ADDON", self)
|
|
FW:UnregisterEvent("BN_FRIEND_ACCOUNT_ONLINE", self)
|
|
FW:Off("BEASTS_UPDATED", self)
|
|
state.queue = {}
|
|
end
|
|
|
|
-- fuer die Einstellungen: Zaehler und Status
|
|
function Sync:Stats()
|
|
local store = Store()
|
|
return {
|
|
sent = store.sent or 0,
|
|
received = store.received or 0,
|
|
lastReceived = store.lastReceived,
|
|
queue = #state.queue,
|
|
global = state.status.global,
|
|
bnet = self:BNAvailable(),
|
|
people = #People(),
|
|
}
|
|
end
|
|
|
|
if Beasts.state == "enabled" then
|
|
Sync:Start()
|
|
end
|