diff --git a/CodexRecipes.lua b/CodexRecipes.lua new file mode 100644 index 0000000..4642533 --- /dev/null +++ b/CodexRecipes.lua @@ -0,0 +1,333 @@ +-- Freewood-OctoWoW-Codex: Rezepte der Berufe +-- Lernt beim Oeffnen eines Berufsfensters jedes Rezept: Ergebnis-Item, Material mit Mengen und die Schwierigkeit +-- (orange/gelb/gruen/grau) bei deinem damaligen Skill. Zusammen mit den Lehrer-Daten (Skill-Anforderung, Preis) +-- weiss der Plan so, was ein Rezept braucht und was es bringt, auch bevor du es gelernt hast. +-- db.recipes[Beruf][Rezeptname] = { it = Ergebnis-Item, rg = { [Materialitem] = Anzahl }, +-- ob = { optimal|medium|easy|trivial = { kleinster, groesster Skill } }, t = Zeit } +-- Nur lokal; das Teilen mit anderen Spielern kommt spaeter. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local R = {} +NS.Recipes = R +Codex.Recipes = R + +local DIFFS = { optimal = true, medium = true, easy = true, trivial = true } +local MAX_RECIPES = 600 + +local function Store() + local db = Codex.db + if not db then return nil end + db.recipes = db.recipes or {} + return db.recipes +end + +local function ItemIDOf(link) + return type(link) == "string" and tonumber(link:match("item:(%d+)")) or nil +end + +-- Schwierigkeit aus Zahl (Enum: 0 optimal, 1 mittel, 2 leicht, 3 trivial) oder Text +local DIFF_NUM = { [0] = "optimal", [1] = "medium", [2] = "easy", [3] = "trivial" } +local function DiffOf(v) + if type(v) == "number" then return DIFF_NUM[v] end + if type(v) == "string" then + v = v:lower() + if DIFFS[v] then return v end + end + return nil +end + +local function Try(fn, ...) + if type(fn) ~= "function" then return nil end + local ok, a, b, c, d = pcall(fn, ...) + if ok then return a, b, c, d end + return nil +end + +-- Neue Berufsoberflaeche (C_TradeSkillUI): { profession, skill, max, list = { { name, item, diff, reag, learned } } } +local function SnapshotNew() + local T = _G.C_TradeSkillUI + if type(T) ~= "table" then return nil end + local base = Try(T.GetBaseProfessionInfo) + local name = type(base) == "table" and (base.professionName or base.parentProfessionName) or nil + local skill = type(base) == "table" and tonumber(base.skillLevel) or nil + local max = type(base) == "table" and tonumber(base.maxSkillLevel) or nil + if not name or name == "" then return nil end + local ids = Try(T.GetAllRecipeIDs) + if type(ids) ~= "table" or #ids == 0 then return nil end + local list = {} + for _, id in ipairs(ids) do + local info = Try(T.GetRecipeInfo, id) + if type(info) == "table" and type(info.name) == "string" then + local diff = DiffOf(info.relativeDifficulty) or DiffOf(info.difficulty) + local item + local link = Try(T.GetRecipeItemLink, id) + item = ItemIDOf(link) + local reag = {} + local schem = Try(T.GetRecipeSchematic, id, false) + if type(schem) == "table" then + item = item or tonumber(schem.outputItemID) + for _, slot in ipairs(schem.reagentSlotSchematics or {}) do + local first = slot.reagents and slot.reagents[1] + local rid = first and tonumber(first.itemID) + if rid and tonumber(slot.quantityRequired) then reag[rid] = tonumber(slot.quantityRequired) end + end + end + if not next(reag) then + local n = Try(T.GetRecipeNumReagents, id) or 0 + for j = 1, n do + local _, _, need = Try(T.GetRecipeReagentInfo, id, j) + local rid = ItemIDOf(Try(T.GetRecipeReagentItemLink, id, j)) + if rid and tonumber(need) then reag[rid] = tonumber(need) end + end + end + list[#list + 1] = { name = info.name, item = item, diff = diff or "optimal", reag = reag, + learned = info.learned ~= false, hasDiff = diff ~= nil, trivial = tonumber(info.maxTrivialLevel) } + end + end + return { profession = name, skill = skill or 0, max = max or 0, list = list } +end + +-- Alte Handwerks-Schnittstelle (GetTradeSkill...) +local function SnapshotOld() + if not (GetTradeSkillLine and GetNumTradeSkills and GetTradeSkillInfo) then return nil end + local ok, name, rank, maxRank = pcall(GetTradeSkillLine) + if not ok or type(name) ~= "string" or name == "" or name == "UNKNOWN" then return nil end + local list = {} + for i = 1, math.min(GetNumTradeSkills() or 0, MAX_RECIPES) do + local rname, rtype = GetTradeSkillInfo(i) + if type(rname) == "string" and DIFFS[rtype] then + local item = GetTradeSkillItemLink and ItemIDOf(GetTradeSkillItemLink(i)) or nil + local reag = {} + local nr = GetTradeSkillNumReagents and GetTradeSkillNumReagents(i) or 0 + for j = 1, nr do + local _, _, need = GetTradeSkillReagentInfo(i, j) + local rid = GetTradeSkillReagentItemLink and ItemIDOf(GetTradeSkillReagentItemLink(i, j)) or nil + if rid and tonumber(need) then reag[rid] = tonumber(need) end + end + list[#list + 1] = { name = rname, item = item, diff = rtype, reag = reag, learned = true, hasDiff = true } + end + end + return { profession = name, skill = tonumber(rank) or 0, max = tonumber(maxRank) or 0, list = list } +end + +-- Offenes Berufsfenster lesen: neue Oberflaeche zuerst, sonst die alte +function R.Snapshot() + return SnapshotNew() or SnapshotOld() +end + +function R.ReadOpenWindow() + local store = Store() + if not store then return false end + local snap = R.Snapshot() + if not snap then return false end + R.last = snap + local prof = store[snap.profession] + if not prof then prof = {}; store[snap.profession] = prof end + local n = 0 + for _ in pairs(prof) do n = n + 1 end + for _, r in ipairs(snap.list) do + local rec = prof[r.name] + if not rec then + if n >= MAX_RECIPES then break end + rec = { rg = {}, ob = {} } + prof[r.name] = rec + n = n + 1 + end + if r.item then rec.it = r.item end + if r.trivial and r.trivial > 0 then rec.tv = r.trivial end -- ab diesem Skill grau + for rid, need in pairs(r.reag) do rec.rg[rid] = need end + if r.learned and r.hasDiff then + local range = rec.ob[r.diff] + if range then + range[1], range[2] = math.min(range[1], snap.skill), math.max(range[2], snap.skill) + else + rec.ob[r.diff] = { snap.skill, snap.skill } + end + end + rec.t = time and time() or 0 + end + FW:Fire("CODEX_RECIPES_CHANGED", snap.profession) + return true, #snap.list +end + +local reading = false +local function OnTradeSkill() + if reading then return end + reading = true + local function Run() + reading = false + Codex.Safe(R.ReadOpenWindow) + end + if C_Timer and C_Timer.After then + C_Timer.After(0.5, Run) + else + Run() + end +end + +-- Diagnose fuer /fwberufdump: zeigt, was das Spiel zurueckgibt +function R.Dump() + local function Say(msg) print("|cffe0b84dFreewood Beruf:|r " .. tostring(msg)) end + local T = _G.C_TradeSkillUI + Say("C_TradeSkillUI: " .. tostring(type(T) == "table") .. ", alte Schnittstelle (GetTradeSkillLine): " .. tostring(type(GetTradeSkillLine) == "function")) + if type(T) == "table" then + local base = Try(T.GetBaseProfessionInfo) + if type(base) == "table" then + local keys = {} + for k, v in pairs(base) do keys[#keys + 1] = k .. "=" .. tostring(v) end + table.sort(keys) + Say("GetBaseProfessionInfo: " .. table.concat(keys, ", ")) + else + Say("GetBaseProfessionInfo: nichts (Berufsfenster offen?)") + end + local ids = Try(T.GetAllRecipeIDs) + Say("GetAllRecipeIDs: " .. (type(ids) == "table" and (#ids .. " Rezepte") or "nichts")) + local id = type(ids) == "table" and ids[1] + if id then + local info = Try(T.GetRecipeInfo, id) + if type(info) == "table" then + local keys = {} + for k, v in pairs(info) do + if type(v) ~= "table" then keys[#keys + 1] = k .. "=" .. tostring(v) end + end + table.sort(keys) + Say("GetRecipeInfo(" .. id .. "): " .. table.concat(keys, ", ")) + else + Say("GetRecipeInfo(" .. id .. "): nichts") + end + local schem = Try(T.GetRecipeSchematic, id, false) + if type(schem) == "table" then + local keys = {} + for k, v in pairs(schem) do keys[#keys + 1] = k .. "=" .. (type(v) == "table" and ("#" .. #v) or tostring(v)) end + table.sort(keys) + Say("GetRecipeSchematic: " .. table.concat(keys, ", ")) + else + Say("GetRecipeSchematic: nichts; NumReagents=" .. tostring(Try(T.GetRecipeNumReagents, id)) .. ", ItemLink=" .. tostring(Try(T.GetRecipeItemLink, id))) + end + end + end + local snap = R.Snapshot() + if snap then + local learned = 0 + for _, r in ipairs(snap.list) do if r.learned then learned = learned + 1 end end + Say(string.format("Auslesen: %s %d/%d, %d Rezepte (%d gelernt)", snap.profession, snap.skill, snap.max, #snap.list, learned)) + local r = snap.list[1] + if r then + local mats = {} + for rid, n in pairs(r.reag) do mats[#mats + 1] = rid .. "x" .. n end + Say(string.format("Beispiel: %s, Ergebnis-Item %s, Farbe %s, Material %s", r.name, tostring(r.item), tostring(r.diff), table.concat(mats, " "))) + end + else + Say("Auslesen: nichts. Bitte das Berufsfenster oeffnen und den Befehl erneut eingeben.") + end + local pn, rn = R.Count() + Say("In der Datenbank: " .. pn .. " Berufe, " .. rn .. " Rezepte") +end + +SLASH_FREEWOODRECIPEDUMP1 = "/fwberufdump" +SlashCmdList["FREEWOODRECIPEDUMP"] = function() R.Dump() end + +function R:Start() + if self.started then return end + self.started = true + for _, event in ipairs({ "TRADE_SKILL_SHOW", "TRADE_SKILL_UPDATE", "TRADE_SKILL_LIST_UPDATE", "TRADE_SKILL_DATA_SOURCE_CHANGED" }) do + FW:RegisterEvent(event, OnTradeSkill, R) + end +end + +function R:Stop() + for _, event in ipairs({ "TRADE_SKILL_SHOW", "TRADE_SKILL_UPDATE", "TRADE_SKILL_LIST_UPDATE", "TRADE_SKILL_DATA_SOURCE_CHANGED" }) do + FW:UnregisterEvent(event, R) + end + self.started = false +end + +function R.Get(profession) + local store = Store() + return store and store[profession] or {} +end + +function R.Count() + local store = Store() + local professions, recipes = 0, 0 + for _, prof in pairs(store or {}) do + professions = professions + 1 + for _ in pairs(prof) do recipes = recipes + 1 end + end + return professions, recipes +end + +-- Skill-Anforderung eines Rezepts: vom Lehrer (kleinster Wert), sonst kleinster Skill, bei dem es orange war +function R.ReqOf(profession, name) + local best + for _, u in pairs(Codex.V().units) do + if u.tr and u.ro and (u.ro.proftrainer or 0) > 0 then + for _, r in pairs(u.tr) do + if r.nm == name and r.sn == profession and tonumber(r.sr) and (not best or r.sr < best) then best = r.sr end + end + end + end + if best then return best, true end + local rec = R.Get(profession)[name] + local o = rec and rec.ob and rec.ob.optimal + if o then return o[1], false end + return nil +end + +-- Schwierigkeit bei gegebenem Skill: aus Beobachtungen, sonst geschaetzt (je 10 Skill pro Farbe). +-- Rueckgabe: "optimal" | "medium" | "easy" | "trivial" | "locked", und ob geschaetzt (true) oder beobachtet (false) +function R.DiffAt(profession, name, skill) + local rec = R.Get(profession)[name] + local ob = rec and rec.ob or {} + for diff, range in pairs(ob) do + if skill >= range[1] and skill <= range[2] then return diff, false end + end + local req = R.ReqOf(profession, name) + if not req and not next(ob) and not (rec and rec.tv) then return nil end + -- Schwellen: gelb, gruen, grau beginnen bei + local base = req or (ob.optimal and ob.optimal[1]) or 0 + local tv = rec and rec.tv + -- Ist die graue Schwelle (maxTrivialLevel) bekannt, liegen gruen und gelb je 10 Skill darunter + local gray = ob.trivial and ob.trivial[1] or tv or math.max(base + 30, ob.easy and ob.easy[2] + 1 or 0) + local green = ob.easy and ob.easy[1] or (tv and math.max(gray - 10, ob.medium and ob.medium[2] + 1 or 0)) or math.max(gray - 10, ob.medium and ob.medium[2] + 1 or 0) + local yellow = ob.medium and ob.medium[1] or math.max(green - 10, ob.optimal and ob.optimal[2] + 1 or 0) + if not req and not ob.optimal and tv then base = math.max(0, yellow - 10) end + local first = req or (ob.optimal and ob.optimal[1]) or 0 + if skill < first then return "locked", true end + if skill < yellow then return "optimal", true end + if skill < green then return "medium", true end + if skill < gray then return "easy", true end + return "trivial", true +end + +-- Rezepte beim Lehrer: Liste { name, profession, req, price, trainerID, rec = Rezept oder nil } +function R.TrainerRows() + local out, seen = {}, {} + for uid, u in pairs(Codex.V().units) do + if u.tr and u.ro and (u.ro.proftrainer or 0) > 0 then + for _, r in pairs(u.tr) do + if r.nm and r.sn then + local key = r.sn .. "\1" .. r.nm + local e = seen[key] + local price = Codex.ModePrice and Codex.ModePrice(r.pr) or nil + if not e then + e = { name = r.nm, profession = r.sn, req = tonumber(r.sr), price = price, trainerID = uid, + rec = R.Get(r.sn)[r.nm] } + seen[key] = e + out[#out + 1] = e + elseif price and (not e.price or price < e.price) then + e.price, e.trainerID = price, uid + end + end + end + end + end + return out +end diff --git a/CodexRoles.lua b/CodexRoles.lua new file mode 100644 index 0000000..3e40e1a --- /dev/null +++ b/CodexRoles.lua @@ -0,0 +1,1289 @@ +-- Freewood-OctoWoW-Codex: Rollen (Haendler, Lehrer, Flugmeister ...) +-- Lernt beim Ansprechen von NPCs und Objekten: +-- units[id].ro[rolle] = Anzahl Beobachtungen (vendor, repair, flight, innkeeper, stablemaster, spirithealer, +-- auctioneer, battlemaster, banker, classtrainer, proftrainer); objects[id].ro: mailbox +-- units[id].ti = Titelzeile ("Waffenschmied") +-- units[id].wa[itemID] = { pr = { [preis] = Anzahl }, st = Stapel, lim = 1 (begrenzt), ec = 1 (Tauschkosten) } +-- alle beobachteten Preise (Ruf und Talente senken Preise); Prozent = Preis / hoechster Preis +-- units[id].tr[schluessel] = { nm, rk, lv = Stufe, sn = Fertigkeit, sr = Fertigkeitswert, pr = { [kosten] = Anzahl } } +-- units[id].fl[ziel] = { [preis] = Anzahl } Flugziele, units[id].fn = eigener Flugpunkt +-- Der Ort (Spielerposition beim Ansprechen) landet wie bei allen Datensaetzen in cells. +-- Alles laeuft geschuetzt; unbekannte Ereignisse werden vom Spiel einfach nicht angeboten. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Util = FW.Util + +local Roles = {} +NS.Roles = Roles +Codex.Roles = Roles + +local MAX_WARES = 250 +local MAX_PRICES = 6 +local MAX_TRAIN = 160 +local MAX_DEST = 60 +local MAX_COUNT = 1000 +local DEBOUNCE = 5 + +Roles.events = {} -- Diagnose: wie oft kam welches Ereignis +Roles.gossip = {} -- Diagnose: gesehene Gespraechs-Arten +Roles.last = nil -- zuletzt erfasste Rolle (Diagnose) +Roles.trainerSources = {} -- Diagnose: woher die Zauber-IDs der Lehrer kamen + +local debounce = {} +local visit = {} -- pro Besuch: bereits gezaehlte Preise + +local function Now() + return (GetTime and GetTime()) or 0 +end + +local function Str(v, max) + return Codex.Str(v, max) +end + +local function Num(v) + if Util.IsSecret(v) or type(v) ~= "number" or v ~= v then + return nil + end + return v +end + +local function ItemIDFromLink(link) + link = Util.Clean(link) + if type(link) ~= "string" then + return nil + end + return tonumber(link:match("item:(%d+)")) +end + +------------------------------------------------------------------------ +-- Wer wird gerade angesprochen +------------------------------------------------------------------------ + +-- Rueckgabe: Art ("npc"/"object"), ID, Name, Einheit +local function Resolve(allowObject, objectOnly) + for _, unit in ipairs(objectOnly and {} or { "npc", "target" }) do + local guid = UnitGUID and UnitGUID(unit) + local kind, id = Codex.ParseGUID(guid) + if kind then + local name = UnitName and Str((UnitName(unit)), 60) or nil + return kind, id, name, unit + end + end + local o = Codex.lastObject + if allowObject and o and Now() - (o.t or 0) < 10 then + return "object", o.id, o.name, nil + end + return nil +end + +local function Entity(kind, id) + if kind == "npc" then + return Codex:Unit(id, true) + end + return Codex:Object(id, true) +end + +-- Titelzeile ("Waffenschmied") aus der zweiten Tooltip-Zeile; Stufen- und Fraktionszeilen zaehlen nicht +local function TitleFrom(text) + text = Str(text, 60) + if not text then + return nil + end + text = text:gsub("^<", ""):gsub(">$", "") + text = text:gsub("^%s+", ""):gsub("%s+$", "") + if text == "" or text:find("%d") or text:find("%?%?") or #text > 40 then + return nil + end + local level = _G.LEVEL + if type(level) == "string" and text:sub(1, #level) == level then + return nil + end + return text +end + +local function ReadTitle(unit) + if not unit or not (C_TooltipInfo and C_TooltipInfo.GetUnit) then + return nil + end + local ok, data = pcall(C_TooltipInfo.GetUnit, unit) + if not ok or type(data) ~= "table" or Util.IsSecret(data) then + return nil + end + if TooltipUtil and TooltipUtil.SurfaceArgs then + pcall(TooltipUtil.SurfaceArgs, data) + end + local line = type(data.lines) == "table" and data.lines[2] or nil + if type(line) ~= "table" then + return nil + end + return TitleFrom(line.leftText) +end + +------------------------------------------------------------------------ +-- Rolle vermerken +------------------------------------------------------------------------ + +-- Gibt die Einheit/das Objekt zurueck (oder nil), vermerkt Rolle, Namen, Titel und Ort (hoechstens alle 5 s je Rolle) +function Roles:Mark(role, allowObject) + local kind, id, name, unit = Resolve(allowObject, role == "mailbox") + if not kind then + return nil + end + if kind == "object" and role ~= "mailbox" then + return nil + end + if kind == "npc" and role == "mailbox" then + return nil + end + local ent = Entity(kind, id) + if name then + ent.n = name + end + if kind == "npc" then + local title = ReadTitle(unit) + if title then + ent.ti = title + end + end + local key = kind .. id .. role + local t = Now() + if not debounce[key] or t - debounce[key] > DEBOUNCE then + debounce[key] = t + ent.ro = ent.ro or {} + ent.ro[role] = math.min((ent.ro[role] or 0) + 1, MAX_COUNT) + Codex.AddCell(ent) + Roles.last = { role = role, kind = kind, id = id } + end + FW:Fire("CODEX_CHANGED", kind == "npc" and "unit" or "object", id) + return ent, kind, id +end + +------------------------------------------------------------------------ +-- Haendler +------------------------------------------------------------------------ + +local function AddPrice(prices, price) + if prices[price] then + prices[price] = math.min(prices[price] + 1, MAX_COUNT) + return + end + local n = 0 + for _ in pairs(prices) do + n = n + 1 + end + if n < MAX_PRICES then + prices[price] = 1 + end +end + +local function MerchantInfo(i) + local name, price, stack, avail, ec + -- Zuerst die native 1.12-Funktion (Name, Textur, Preis, Menge, Vorrat) + if GetMerchantItemInfo then + local ok, n, _, p, q, a = pcall(GetMerchantItemInfo, i) + if ok then + name, price, stack, avail = n, p, q, a + end + end + -- Luecken mit der Tabellen-Abfrage fuellen (falls vorhanden) + if (name == nil or price == nil) and C_MerchantFrame and C_MerchantFrame.GetItemInfo then + local ok, info = pcall(C_MerchantFrame.GetItemInfo, i) + if ok and type(info) == "table" and not Util.IsSecret(info) then + if name == nil then name = info.name end + if price == nil then price = info.price end + if stack == nil then stack = info.stackCount end + if avail == nil then avail = info.numAvailable end + ec = info.hasExtendedCost + end + end + if ec == nil and GetMerchantItemCostInfo then + local ok, count = pcall(GetMerchantItemCostInfo, i) + ec = ok and type(count) == "number" and count > 0 + end + return Str(name, 80), Num(price), Num(stack), Num(avail), ec == true +end + +local function MerchantItemID(i) + local link + if GetMerchantItemLink then + link = GetMerchantItemLink(i) + end + local id = ItemIDFromLink(link) + if id then + return id + end + if C_MerchantFrame and C_MerchantFrame.GetItemInfo then + local ok, info = pcall(C_MerchantFrame.GetItemInfo, i) + if ok and type(info) == "table" then + return Num(info.itemID) or ItemIDFromLink(info.link) + end + end + return nil +end + +-- Ruf-Anforderung einer Ware aus dem Tooltip des Haendlerfensters ("Benoetigt Fraktion - Stufe"). +-- Rueckgabe: Tabelle { f, s, ok } | false (kein Ruf noetig) | nil (Tooltip nicht lesbar) +local function ReqPattern() + local fmt = _G.ITEM_REQ_REPUTATION + if type(fmt) ~= "string" or not fmt:find("%%s") then return nil end + local esc = fmt:gsub("([%^%$%(%)%.%[%]%*%+%-%?])", "%%%1"):gsub("%%s", "(.+)") + return "^" .. esc .. "$" +end + +local function StandingIndex(text) + local low = tostring(text or ""):lower() + for i = 1, 8 do + for _, key in ipairs({ "FACTION_STANDING_LABEL" .. i, "FACTION_STANDING_LABEL" .. i .. "_FEMALE" }) do + local label = _G[key] + if type(label) == "string" and label:lower() == low then return i end + end + end + return nil +end + +local function IsRed(c) + return type(c) == "table" and (c.r or c[1] or 0) > 0.8 and (c.g or c[2] or 1) < 0.3 and (c.b or c[3] or 1) < 0.3 +end + +function Codex.ReputationRequirement(data) + if type(data) ~= "table" or type(data.lines) ~= "table" then return nil end + local pat = ReqPattern() or "^Requires (.+) %- (.+)$" + for _, line in ipairs(data.lines) do + local text = type(line) == "table" and Str(line.leftText, 120) + if text then + local fac, stand = text:match(pat) + if not fac then fac, stand = text:match("^Ben\195\182tigt (.+) %- (.+)$") end + if not fac then + -- Ersatz: " - " mit einer bekannten Ruf-Stufe am Ende + local f2, s2 = text:match("^(.-)%s+[%-\226\128\147]+%s+(.+)$") + if f2 and s2 and StandingIndex(s2) then fac, stand = f2, s2 end + end + if fac and stand then + local idx = StandingIndex(stand) + if idx then + return { f = fac:sub(1, 40), s = idx, ok = not IsRed(line.leftColor) } + end + end + end + end + return false +end + +local function MerchantTooltip(i) + if not (C_TooltipInfo and C_TooltipInfo.GetMerchantItem) then return nil end + local ok, data = pcall(C_TooltipInfo.GetMerchantItem, i) + if not ok or type(data) ~= "table" or Util.IsSecret(data) then return nil end + if TooltipUtil and TooltipUtil.SurfaceArgs then pcall(TooltipUtil.SurfaceArgs, data) end + return data +end + +-- /fwruf: zeigt fuer die ersten Waren des offenen Haendlers die Tooltip-Zeilen und was daraus gelesen wurde +function Roles.RepDiag() + if not (GetMerchantNumItems and (GetMerchantNumItems() or 0) > 0) then + FW:Print("Ruf: Bitte zuerst ein Haendlerfenster oeffnen.") + return + end + if not (C_TooltipInfo and C_TooltipInfo.GetMerchantItem) then + FW:Print("Ruf: C_TooltipInfo.GetMerchantItem gibt es hier nicht - Ruf-Zeilen koennen nicht gelesen werden.") + return + end + FW:Print("Ruf: Muster = " .. tostring(_G.ITEM_REQ_REPUTATION) .. " | Stufen: " .. tostring(_G.FACTION_STANDING_LABEL5) .. ", " .. tostring(_G.FACTION_STANDING_LABEL6)) + local shown = 0 + for i = 1, math.min(GetMerchantNumItems(), MAX_WARES) do + local tip = MerchantTooltip(i) + local rq = tip and Codex.ReputationRequirement(tip) + if rq or shown < 2 then + shown = shown + 1 + local texts = {} + for _, line in ipairs(tip and tip.lines or {}) do + if type(line) == "table" and type(line.leftText) == "string" then texts[#texts + 1] = line.leftText end + end + FW:Print(string.format("Ruf: Ware %d: %s -> %s", i, #texts > 0 and table.concat(texts, " | "):sub(1, 160) or "(kein Tooltip)", + rq and string.format("%s Stufe %d %s", rq.f, rq.s, rq.ok and "erfuellt" or "NICHT erfuellt") or (rq == false and "kein Ruf noetig" or "nicht lesbar"))) + end + if shown >= 8 then break end + end +end +SLASH_FREEWOODREPDIAG1 = "/fwruf" +SlashCmdList["FREEWOODREPDIAG"] = function() Roles.RepDiag() end + +function Roles:ScanMerchant() + local count = 0 + if GetMerchantNumItems then + count = GetMerchantNumItems() or 0 + elseif C_MerchantFrame and C_MerchantFrame.GetNumItems then + count = C_MerchantFrame.GetNumItems() or 0 + end + if Util.IsSecret(count) or type(count) ~= "number" or count <= 0 then + return + end + local ent, _, vid = self:Mark("vendor") + if not ent then + return + end + -- Fraktion des Haendlers (Horde / Allianz) merken + if vid and UnitFactionGroup then + local ok, group = pcall(UnitFactionGroup, "npc") + if ok and not Util.IsSecret(group) and (group == "Alliance" or group == "Horde") then + Codex.db.vfac = Codex.db.vfac or {} + Codex.db.vfac[vid] = group == "Alliance" and "A" or "H" + end + end + local wrq + if vid then + Codex.db.wrq = Codex.db.wrq or {} + wrq = Codex.db.wrq[vid] + end + if CanMerchantRepair and CanMerchantRepair() then + self:Mark("repair") + end + ent.wa = ent.wa or {} + local seen = visit.wares or {} + visit.wares = seen + local changed = false + for i = 1, math.min(count, MAX_WARES) do + local id = MerchantItemID(i) + local name, price, stack, avail, ec = MerchantInfo(i) + if i == 1 then + local lk = GetMerchantItemLink and GetMerchantItemLink(1) + Roles.diag = { count = count, id = id, name = name, price = price, link = lk and tostring(lk):gsub("|", "!") } + end + visit.rep = visit.rep or {} + if id and wrq ~= false and vid and not visit.rep[id] then + local tip = MerchantTooltip(i) + if tip then + visit.rep[id] = true -- Ruf-Tooltip je Besuch nur einmal lesen (MERCHANT_UPDATE kommt oft) + local rq = Codex.ReputationRequirement(tip) + Codex.db.wrq[vid] = Codex.db.wrq[vid] or {} + wrq = Codex.db.wrq[vid] + wrq.v = 1 + wrq[id] = rq or nil + end + end + if id and price then + local ware = ent.wa[id] + if not ware then + local n = 0 + for _ in pairs(ent.wa) do + n = n + 1 + end + if n < MAX_WARES then + ware = { pr = {} } + ent.wa[id] = ware + end + end + if ware then + Codex.NoteName(id, name) + if stack and stack > 1 then + ware.st = math.min(math.floor(stack), 1000) + end + if avail and avail >= 0 then + ware.lim = 1 + end + if ec then + ware.ec = 1 + end + local token = id .. ":" .. math.floor(price) + if not seen[token] then + seen[token] = true + AddPrice(ware.pr, math.floor(price)) + changed = true + end + end + end + end + if changed then + FW:Fire("CODEX_CHANGED", "unit", Roles.last and Roles.last.id) + end +end + +------------------------------------------------------------------------ +-- Flugmeister +------------------------------------------------------------------------ + +function Roles:ScanTaxi() + if not NumTaxiNodes then + return + end + local count = NumTaxiNodes() + if Util.IsSecret(count) or type(count) ~= "number" or count <= 0 then + return + end + local ent = self:Mark("flight") + if not ent then + return + end + ent.fl = ent.fl or {} + local seen = visit.taxi or {} + visit.taxi = seen + local nDest = 0 + for _ in pairs(ent.fl) do + nDest = nDest + 1 + end + for i = 1, math.min(count, 120) do + local name = TaxiNodeName and Str(TaxiNodeName(i), 60) + local kind = TaxiNodeGetType and TaxiNodeGetType(i) + if name and kind == "CURRENT" then + ent.fn = name + elseif name and (kind == "REACHABLE" or kind == nil) then + local cost = Num(TaxiNodeCost and TaxiNodeCost(i)) or 0 + cost = math.floor(cost) + local dest = ent.fl[name] + if not dest and nDest < MAX_DEST then + dest = {} + ent.fl[name] = dest + nDest = nDest + 1 + end + if dest and not seen[name .. ":" .. cost] then + seen[name .. ":" .. cost] = true + AddPrice(dest, cost) + end + end + end +end + +------------------------------------------------------------------------ +-- Lehrer +------------------------------------------------------------------------ + +-- Lehrer-Funktionen: erst die klassische Form, dann C_Trainer (Antworten als Liste oder als Tabelle) +local function TrainerCall(name, ...) + local fn = _G[name] + if type(fn) ~= "function" and C_Trainer and type(C_Trainer[name]) == "function" then + fn = C_Trainer[name] + end + if type(fn) ~= "function" then + return nil + end + local r = { pcall(fn, ...) } + if not r[1] then + return nil + end + table.remove(r, 1) + return r +end + +local function FirstNumber(r, field) + local v = r and r[1] + if type(v) == "table" then + v = v[field] or v.cost or v.moneyCost + end + return Num(v) +end + +local TRAINER_STATES = { available = true, used = true, unavailable = true, header = true, none = true } + +-- Rang einer Lehrerzeile aus dem Tooltip (rechte Seite der ersten Zeile, zum Beispiel "Rang 2") +-- Zauber-ID einer Lehrerzeile: Link der Zeile, Tabellenfeld oder Tooltip-Daten. Rueckgabe: ID, Quelle +local function TrainerSpell(i, info) + local r = TrainerCall("GetTrainerServiceItemLink", i) + local link = r and Util.Clean(r[1]) + if type(link) == "string" then + local id = tonumber(link:match("spell:(%d+)")) or tonumber(link:match("enchant:(%d+)")) + if id then + return id, "link" + end + end + if type(info) == "table" then + local id = Num(info.spellID) or Num(info.spellId) + if id and id > 0 then + return id, "info" + end + end + for _, fname in ipairs({ "GetTrainerServiceSpellID", "GetTrainerServiceSpell" }) do + local rr = TrainerCall(fname, i) + local id = rr and Num(rr[1]) + if id and id > 0 then + return id, fname + end + end + if C_TooltipInfo and C_TooltipInfo.GetTrainerService then + local ok, data = pcall(C_TooltipInfo.GetTrainerService, i) + if ok and type(data) == "table" and not Util.IsSecret(data) then + local id = Num(data.spellID) or Num(data.spellId) + if not id and Enum and Enum.TooltipDataType and data.type == Enum.TooltipDataType.Spell then + id = Num(data.id) + end + if id and id > 0 then + return id, "tooltip" + end + end + end + return nil +end + +local function SpellRank(id) + local text + if C_Spell and C_Spell.GetSpellSubtext then + local ok, v = pcall(C_Spell.GetSpellSubtext, id) + text = ok and v or nil + elseif GetSpellSubtext then + local ok, v = pcall(GetSpellSubtext, id) + text = ok and v or nil + end + return Str(text, 30) +end + +function Codex.SpellRank(id) + return SpellRank(id) +end + +function Codex.SpellIcon(id) + if C_Spell and C_Spell.GetSpellInfo then + local ok, info = pcall(C_Spell.GetSpellInfo, id) + if ok and type(info) == "table" and info.iconID then + return info.iconID + end + end + if GetSpellTexture then + local ok, v = pcall(GetSpellTexture, id) + if ok then + return v + end + end + return nil +end + +local function TrainerRank(i) + if not (C_TooltipInfo and C_TooltipInfo.GetTrainerService) then + return nil + end + local ok, data = pcall(C_TooltipInfo.GetTrainerService, i) + if not ok or type(data) ~= "table" or Util.IsSecret(data) then + return nil + end + if TooltipUtil and TooltipUtil.SurfaceArgs then + pcall(TooltipUtil.SurfaceArgs, data) + end + for n = 1, 3 do + local line = type(data.lines) == "table" and data.lines[n] or nil + if type(line) == "table" then + local text = Str(line.rightText, 30) + if text and text:find("%d") and #text <= 20 then + return (text:gsub("^%(", ""):gsub("%)$", "")) + end + end + end + return nil +end + +function Roles:ScanTrainer() + local trade = TrainerCall("IsTradeskillTrainer") + local role = (trade and trade[1] == true) and "proftrainer" or "classtrainer" + local ent = self:Mark(role) + if not ent then + return + end + local c = TrainerCall("GetNumTrainerServices") + local count = FirstNumber(c) + Roles.trainerDiag = string.format("%s: %s", tostring(role), tostring(count)) + if not count or count <= 0 then + return + end + ent.tr = ent.tr or {} + if Codex.LearnPlanReset then Codex.LearnPlanReset() end + -- Klasse des Spielers merken: damit gehoert der Lehrer auch dann zu einer Klasse, wenn der Titel unbekannt ist + if role == "classtrainer" and not ent.trc then + local token + if UnitClass then + local _, t = UnitClass("player") + token = t + end + if type(token) == "string" and not Util.IsSecret(token) and Codex.CLASS_TOKENS[token] then ent.trc = token end + end + for key, rec in pairs(ent.tr) do + if rec.rk and TRAINER_STATES[rec.rk] then + ent.tr[key] = nil -- aeltere Fehlerfassung: Zustand statt Rang + end + end + local seen = visit.train or {} + visit.train = seen + local n = 0 + for _ in pairs(ent.tr) do + n = n + 1 + end + for i = 1, math.min(count, 400) do + local r = TrainerCall("GetTrainerServiceInfo", i) or {} + local name, rank, category = r[1], r[2], r[3] + if type(name) == "table" then + local t = name + name, rank, category = t.name, t.subText or t.rank or t.subtext, t.category or t.state + end + -- Forever liefert (Name, Zustand) ohne Rang: Zustand erkennen, Rang woanders suchen + if type(rank) == "string" and TRAINER_STATES[rank] then + category, rank = rank, nil + end + name, rank = Str(name, 60), Str(rank, 30) + local sp, source + if name and category ~= "header" then + sp, source = TrainerSpell(i, r[1]) + if sp then + Roles.trainerSources[source] = (Roles.trainerSources[source] or 0) + 1 + rank = rank or SpellRank(sp) + end + if not rank then + rank = TrainerRank(i) + end + end + if name and category ~= "header" then + local lv = FirstNumber(TrainerCall("GetTrainerServiceLevelReq", i), "level") + lv = lv and lv > 0 and math.floor(lv) or nil + -- Schluessel: Zauber-ID, sonst Name mit Rang, sonst Name mit Stufe + local plain = name .. (rank and ("#" .. rank) or (lv and ("#L" .. lv)) or "") + local key = sp and ("s" .. sp) or plain + if sp then + ent.tr[plain] = nil -- aeltere Eintraege ohne Zauber-ID + ent.tr[name .. (lv and ("#L" .. lv) or "")] = nil + end + local rec = ent.tr[key] + if not rec and n < MAX_TRAIN then + rec = { nm = name, rk = rank, pr = {} } + ent.tr[key] = rec + n = n + 1 + end + if rec and sp then + rec.sp = sp + rec.rk = rank or rec.rk + end + if rec then + if lv then + rec.lv = lv + end + local sk = TrainerCall("GetTrainerServiceSkillReq", i) + if sk then + local sn, sr = Str(sk[1], 40), Num(sk[2]) + if sn then + rec.sn, rec.sr = sn, sr and math.floor(sr) or nil + end + end + local cost = FirstNumber(TrainerCall("GetTrainerServiceCost", i), "moneyCost") + if cost and not seen[key .. ":" .. cost] then + seen[key .. ":" .. cost] = true + AddPrice(rec.pr, math.floor(cost)) + end + end + end + end +end + +------------------------------------------------------------------------ +-- Gespraeche und allgemeine Fenster +------------------------------------------------------------------------ + +local GOSSIP_ROLE = { + binder = "innkeeper", spiritguide = "spirithealer", battlemaster = "battlemaster", banker = "banker", + auctioneer = "auctioneer", taxi = "flight", vendor = "vendor", stable = "stablemaster", stablemaster = "stablemaster", +} + +local function GossipKinds() + local kinds = {} + if GetGossipOptions then + local t = { GetGossipOptions() } + for i = 2, #t, 2 do + if type(t[i]) == "string" then + kinds[#kinds + 1] = t[i] + end + end + end + if #kinds == 0 and C_GossipInfo and C_GossipInfo.GetOptions then + local ok, list = pcall(C_GossipInfo.GetOptions) + if ok and type(list) == "table" then + local icons + if Enum and Enum.GossipOptionIcon then + icons = {} + for name, value in pairs(Enum.GossipOptionIcon) do + icons[value] = tostring(name):lower() + end + end + for _, opt in ipairs(list) do + if type(opt) == "table" then + local kind = type(opt.type) == "string" and opt.type or (icons and icons[opt.icon]) or nil + if kind then + kinds[#kinds + 1] = kind:lower() + end + end + end + end + end + return kinds +end + +function Roles:OnGossip() + local done = {} + for _, kind in ipairs(GossipKinds()) do + Roles.gossip[kind] = (Roles.gossip[kind] or 0) + 1 + local role = GOSSIP_ROLE[kind] + if not role then + for word, r in pairs(GOSSIP_ROLE) do + if kind:find(word, 1, true) then + role = r + end + end + end + if role and not done[role] then + done[role] = true + self:Mark(role) + end + end +end + +-- PLAYER_INTERACTION_MANAGER_FRAME_SHOW: Enum.PlayerInteractionType -> Rolle +local INTERACTION_ROLE = { + Banker = "banker", Auctioneer = "auctioneer", TaxiNode = "flight", StableMaster = "stablemaster", + SpiritHealer = "spirithealer", Binder = "innkeeper", BattleMaster = "battlemaster", MailInfo = "mailbox", + Merchant = "vendor", +} + +function Roles:OnInteraction(ty) + local names = Roles.interactionNames + if not names then + names = {} + if Enum and Enum.PlayerInteractionType then + for name, value in pairs(Enum.PlayerInteractionType) do + names[value] = name + end + end + Roles.interactionNames = names + end + local name = names[ty] + if name then + Roles.gossip["frame:" .. name] = (Roles.gossip["frame:" .. name] or 0) + 1 + end + if name == "Trainer" then + Roles.trainerOpen = true + visit.train = nil + self:ScanTrainer() + return + end + local role = name and INTERACTION_ROLE[name] + if role and role ~= "vendor" and role ~= "flight" then + self:Mark(role, role == "mailbox") + end +end + +------------------------------------------------------------------------ +-- Start / Stopp +------------------------------------------------------------------------ + +local SIMPLE = { + PET_STABLE_SHOW = "stablemaster", + AUCTION_HOUSE_SHOW = "auctioneer", + BANKFRAME_OPENED = "banker", + BATTLEFIELDS_SHOW = "battlemaster", + MAIL_SHOW = "mailbox", +} + +local EVENTS = { "MERCHANT_SHOW", "MERCHANT_UPDATE", "MERCHANT_CLOSED", "TAXIMAP_OPENED", "TRAINER_SHOW", "TRAINER_UPDATE", "TRAINER_CLOSED", + "GOSSIP_SHOW", "PLAYER_INTERACTION_MANAGER_FRAME_SHOW" } + +local function Handler(event) + return function(_, ...) + Roles.events[event] = (Roles.events[event] or 0) + 1 + if not (Codex.db and Codex.db.settings and Codex.db.settings.enabled) then + return + end + Codex.Safe(function(...) + if event == "MERCHANT_SHOW" then + visit.wares = nil + visit.rep = nil + Roles:ScanMerchant() + elseif event == "MERCHANT_UPDATE" then + Roles:ScanMerchant() + elseif event == "MERCHANT_CLOSED" then + visit.wares = nil + visit.rep = nil + elseif event == "TAXIMAP_OPENED" then + visit.taxi = nil + Roles:ScanTaxi() + elseif event == "TRAINER_SHOW" then + visit.train = nil + Roles.trainerOpen = true + Roles:ScanTrainer() + elseif event == "TRAINER_UPDATE" then + if Roles.trainerOpen then + Roles:ScanTrainer() + end + elseif event == "TRAINER_CLOSED" then + Roles.trainerOpen = false + elseif event == "GOSSIP_SHOW" then + Roles:OnGossip() + elseif event == "PLAYER_INTERACTION_MANAGER_FRAME_SHOW" then + Roles:OnInteraction((...)) + elseif SIMPLE[event] then + Roles:Mark(SIMPLE[event], event == "MAIL_SHOW") + end + end, ...) + end +end + +function Roles:Start() + if self.started then + return + end + self.started = true + self.registered = {} + local function reg(event) + if FW:RegisterEvent(event, Handler(event), Roles) then + self.registered[#self.registered + 1] = event + end + end + for _, event in ipairs(EVENTS) do + reg(event) + end + for event in pairs(SIMPLE) do + reg(event) + end +end + +function Roles:Stop() + for _, event in ipairs(self.registered or {}) do + FW:UnregisterEvent(event, Roles) + end + self.registered = {} + self.started = false +end + +-- Ausgabe fuer /fwcodex roles +function Roles:Debug() + local L = FW.L + local list = {} + for event, n in pairs(Roles.events) do + list[#list + 1] = event .. " x" .. n + end + table.sort(list) + FW:Print(string.format(L.CODEX_ROLES_EVENTS, #list > 0 and table.concat(list, ", ") or "-")) + local g = {} + for kind, n in pairs(Roles.gossip) do + g[#g + 1] = kind .. " x" .. n + end + table.sort(g) + FW:Print(string.format(L.CODEX_ROLES_GOSSIP, #g > 0 and table.concat(g, ", ") or "-")) + local counts = {} + for _, group in ipairs({ Codex.db.units, Codex.db.objects }) do + for _, e in pairs(group) do + for role in pairs(e.ro or {}) do + counts[role] = (counts[role] or 0) + 1 + end + end + end + local c = {} + for role, n in pairs(counts) do + c[#c + 1] = (L["CODEX_ROLE_" .. role] or role) .. " " .. n + end + table.sort(c) + FW:Print(string.format(L.CODEX_ROLES_COUNTS, #c > 0 and table.concat(c, ", ") or "-")) + if Roles.trainerDiag then + local src = {} + for k, v in pairs(Roles.trainerSources) do src[#src + 1] = k .. " x" .. v end + table.sort(src) + FW:Print("Trainer: " .. Roles.trainerDiag .. " | Zauber-IDs: " .. (#src > 0 and table.concat(src, ", ") or "keine")) + end + if Roles.last then + FW:Print(string.format(L.CODEX_ROLES_LAST, tostring(Roles.last.role), tostring(Roles.last.kind), tostring(Roles.last.id))) + end +end + +------------------------------------------------------------------------ +-- Lesen (fuer die Oberflaeche): eigene + geteilte Daten +------------------------------------------------------------------------ + +-- Dienste: wer so etwas anbietet, gilt ohne eigene Kampfdaten nicht als Kampfkreatur +local SERVICE = { + vendor = true, repair = true, flight = true, innkeeper = true, stablemaster = true, spirithealer = true, + auctioneer = true, battlemaster = true, banker = true, classtrainer = true, proftrainer = true, +} + +-- Rollen einer Kreatur/eines Objekts aus Live-Daten und Vorlage: { rolle = true|Vorlagewert } +-- kind = "units" | "objects". Zusaetzlich fuer Kreaturen: combat (Kampfkreatur), elite, boss +function Codex.RolesOf(kind, id) + local out = {} + local seedReady = Codex.Seed and Codex.Seed.Ready and Codex.Seed.Ready() + local seed = seedReady and Codex.Seed.Roles(kind, id) or nil + for role, v in pairs(seed or {}) do + out[role] = v + end + local e = Codex.V and Codex.V()[kind] and Codex.V()[kind][id] + if e then + for role in pairs(e.ro or {}) do + out[role] = true + end + end + if kind == "units" then + local fought = e and ((e.k or 0) > 0 or e.tt ~= nil or e.xp ~= nil or e.xm ~= nil or e.d ~= nil) + local class = e and e.c + local rank + local known = fought or class ~= nil + if seedReady then + local ent = Codex.Seed.Entity("units", id) + if ent then + rank = ent.rank + known = known or ent.l1 ~= nil + end + end + if class == "rare" or class == "rareelite" or rank == 4 then + out.rares = true + end + if class == "elite" or class == "rareelite" or class == "worldboss" or rank == 1 or rank == 2 or rank == 3 then + out.elite = true + end + if class == "worldboss" or rank == 3 then + out.boss = true + end + local service = false + for role in pairs(out) do + if SERVICE[role] then service = true end + end + if fought or (known and not service) then + out.combat = true + end + end + return out +end + +-- Rollen in fester Reihenfolge (Auswahlliste) +Roles.ORDER = { + units = { "combat", "elite", "boss", "vendor", "repair", "flight", "innkeeper", "stablemaster", "spirithealer", "auctioneer", + "battlemaster", "banker", "classtrainer", "proftrainer", "rares" }, + objects = { "mailbox", "meetingstone", "chests", "herbs", "mines", "fish" }, +} +-- Nur Auswahl, nicht in Listenzeilen und Details (Rang steht dort schon) +local FILTER_ONLY = { combat = true, elite = true, boss = true } + +function Codex.RoleList(kind, id) + local have = Codex.RolesOf(kind, id) + local list = {} + for _, role in ipairs(Roles.ORDER[kind] or {}) do + if have[role] and not FILTER_ONLY[role] then + list[#list + 1] = role + end + end + return list +end + +------------------------------------------------------------------------ +-- Lernplan: was lernt eine Klasse auf welcher Stufe (aus den Lehrer-Daten) +------------------------------------------------------------------------ + +-- Titelwoerter je Klasse (Fallback, falls die Klassennamen des Spiels nicht passen). Das laengste Wort gewinnt, +-- darum steht "teufelsjaeger" vor "jaeger" nicht im Weg. +local CLASS_WORDS = { + WARRIOR = { "krieger", "warrior", "guerrier", "guerrero", "guerreiro", "guerriero" }, + PALADIN = { "paladin", "paladín" }, + HUNTER = { "jäger", "jaeger", "hunter", "chasseur", "cazador", "caçador", "cacciatore" }, + ROGUE = { "schurke", "schurken", "rogue", "voleur", "pícaro", "ladino", "ladro" }, + PRIEST = { "priester", "priest", "prêtre", "sacerdote" }, + SHAMAN = { "schamane", "schamanen", "shaman", "chaman", "chamán", "xamã", "sciamano" }, + MAGE = { "magier", "magie", "mage", "mago" }, + WARLOCK = { "hexenmeister", "warlock", "démoniste", "brujo", "bruxo", "stregone" }, + DRUID = { "druide", "druiden", "druid", "druide", "druida" }, + DEMONHUNTER = { "teufelsjäger", "dämonenjäger", "demon hunter", "chasseur de démons", "cazador de demonios" }, + DEATHKNIGHT = { "todesritter", "death knight", "chevalier de la mort", "caballero de la muerte" }, + MONK = { "mönch", "monk", "moine", "monje", "monge", "monaco" }, + EVOKER = { "rufer", "evoker", "évocateur", "evocador" }, +} +Codex.CLASS_TOKENS = {} +for token in pairs(CLASS_WORDS) do Codex.CLASS_TOKENS[token] = true end + +-- Klasse eines Lehrers aus der Titelzeile ("Hexenmeisterlehrer" -> WARLOCK) +function Codex.ClassOfTitle(title) + if type(title) ~= "string" or title == "" then return nil end + local low = title:lower() + local best, bestLen + for token, words in pairs(CLASS_WORDS) do + for _, w in ipairs(words) do + if #w >= 4 and low:find(w, 1, true) and (not bestLen or #w > bestLen) then + best, bestLen = token, #w + end + end + end + for _, names in ipairs({ LOCALIZED_CLASS_NAMES_MALE or {}, LOCALIZED_CLASS_NAMES_FEMALE or {} }) do + for token, name in pairs(names) do + if type(name) == "string" and name ~= "" then + -- auch verkuerzte Formen: "Magier" -> "Magie" (Magielehrer), "Magierin" -> "Magier" + local full = name:lower() + local stems = { full, (full:gsub("in$", "")), (full:gsub("er$", "e")) } + for _, stem in ipairs(stems) do + if #stem >= 4 and low:find(stem, 1, true) and (not bestLen or #stem > bestLen) then + best, bestLen = token, #stem + end + end + end + end + end + return best +end + +-- Klasse eines Lehrers: aus dem Titel, sonst die Klasse des Spielers, der das Lehrerfenster geoeffnet hat +-- (Klassenlehrer anderer Klassen lassen sich im Spiel nicht oeffnen) +function Codex.ClassOfTrainer(u) + if type(u) ~= "table" or not u.tr then return nil end + local byTitle = Codex.ClassOfTitle(u.ti) + if byTitle then return byTitle end + if u.ro and (u.ro.classtrainer or 0) > 0 and Codex.CLASS_TOKENS[u.trc or ""] then return u.trc end + return nil +end + +function Codex.ModePrice(pr) + local price, count + for p, n in pairs(pr or {}) do + if not count or n > count or (n == count and p > price) then price, count = p, n end + end + return price +end + +local function ModePrice(pr) + local price, count + for p, n in pairs(pr or {}) do + if not count or n > count or (n == count and p > price) then price, count = p, n end + end + return price +end + +-- Lehrer fuer Begleiter-Fertigkeiten (Tierausbilder) +local function IsPetTrainer(u) + local t = tostring(u and u.ti or ""):lower() + return t:find("tierausbilder", 1, true) ~= nil or t:find("pet trainer", 1, true) ~= nil or t:find("tiertrainer", 1, true) ~= nil +end + +-- Der Plan wird aus allen Lehrerdaten gebaut (ca. 10 ms) und aendert sich selten: Zwischenspeicher mit kurzer Lebensdauer. +-- Daten-Aenderungen (Scan, Teilen, Bearbeiten) rufen Codex.LearnPlanReset() auf. +local planCache = {} +local PLAN_TTL = 20 +function Codex.LearnPlanReset() planCache = {} end + +local BuildLearnPlan + +-- Ergebnis: { levels = { [lv] = { lv, spells = {...}, cost, trainers = {id...} } }, order = { lv... } } +function Codex.LearnPlan(token) + local now = _G.GetTime and _G.GetTime() or 0 + local c = planCache[token] + if c and now >= c.t and now - c.t < PLAN_TTL then return c.plan end + local plan = BuildLearnPlan(token) + planCache[token] = { t = now, plan = plan } + return plan +end + +BuildLearnPlan = function(token) + local levels, order = {}, {} + local seen = {} + -- Manche Lehrer-Eintraege haben keine Stufe (nicht gelesen). Die Stufe steht aber meist bei einem anderen Lehrer derselben + -- Klasse: dieselbe Zauber-ID, sonst derselbe Name mit demselben Rang. + local lvOf = {} + for _, u in pairs(Codex.V().units) do + if u.tr and Codex.ClassOfTrainer(u) == token then + for _, r in pairs(u.tr) do + if r.lv then + if r.sp then lvOf["s" .. r.sp] = r.lv end + if r.nm then lvOf[r.nm .. "|" .. (r.rk or "")] = r.lv end + end + end + end + end + for id, u in pairs(Codex.V().units) do + if Codex.ClassOfTrainer(u) == token then + for key, r in pairs(u.tr) do + local lv = r.lv or (r.sp and lvOf["s" .. r.sp]) or lvOf[(r.nm or "") .. "|" .. (r.rk or "")] or 0 + local skey = lv .. "\1" .. (r.sp and ("s" .. r.sp) or key) + local sp = seen[skey] + if not sp then + sp = { nm = r.nm, sp = r.sp, rk = r.rk, lv = lv, pr = {}, trainers = {} } + seen[skey] = sp + local g = levels[lv] + if not g then + g = { lv = lv, spells = {}, trainers = {}, trainerSet = {}, cost = 0 } + levels[lv] = g + order[#order + 1] = lv + end + g.spells[#g.spells + 1] = sp + end + sp.rk = sp.rk or r.rk + sp.sp = sp.sp or r.sp + for p, n in pairs(r.pr or {}) do sp.pr[p] = (sp.pr[p] or 0) + n end + local g = levels[lv] + if IsPetTrainer(u) then sp.pet = true end + if not sp.trainers[id] then + sp.trainers[id] = true + if not g.trainerSet[id] then + g.trainerSet[id] = true + g.trainers[#g.trainers + 1] = id + end + end + end + end + end + table.sort(order) + for _, lv in ipairs(order) do + local g = levels[lv] + table.sort(g.spells, function(a, b) + if (a.nm or "") ~= (b.nm or "") then return (a.nm or "") < (b.nm or "") end + return (a.rk or "") < (b.rk or "") + end) + table.sort(g.trainers) + for _, sp in ipairs(g.spells) do + sp.cost = ModePrice(sp.pr) + g.cost = g.cost + (sp.cost or 0) + end + end + return { levels = levels, order = order } +end + +-- Klassen, fuer die Lehrer-Daten vorliegen +function Codex.LearnClasses() + local set, list = {}, {} + for _, u in pairs(Codex.V().units) do + local t = Codex.ClassOfTrainer(u) + if t and not set[t] then set[t] = true; list[#list + 1] = t end + end + table.sort(list) + return list +end + +-- Gesamtkosten aller Stufen in (von, bis] +function Codex.LearnCost(plan, from, to) + local sum = 0 + for _, lv in ipairs(plan.order) do + if lv > from and lv <= to then sum = sum + plan.levels[lv].cost end + end + return sum +end + +------------------------------------------------------------------------ +-- Favoriten und Notizen (nur lokal, wird nicht geteilt) +------------------------------------------------------------------------ + +local FAV_PREFIX = { quests = "q", units = "u", objects = "o", items = "i" } +local MAX_NOTE = 200 + +local function FavKey(kind, id) + local p = FAV_PREFIX[kind] + return (p and id ~= nil) and (p .. tostring(id)) or nil +end + +function Codex.FavSupported(kind) return FAV_PREFIX[kind] ~= nil end + +function Codex.GetFav(kind, id) + local key = FavKey(kind, id) + return key and Codex.db.fav and Codex.db.fav[key] or nil +end + +function Codex.IsFav(kind, id) + return Codex.GetFav(kind, id) ~= nil +end + +function Codex.SetFav(kind, id, on) + local key = FavKey(kind, id) + if not key then return false end + Codex.db.fav = Codex.db.fav or {} + if on then + Codex.db.fav[key] = Codex.db.fav[key] or { t = time and time() or 0 } + else + Codex.db.fav[key] = nil + end + return true +end + +function Codex.SetNote(kind, id, text) + local key = FavKey(kind, id) + if not key then return false end + -- Zeichen, die Farbcodes oder das Austauschformat stoeren wuerden, ersetzen + text = tostring(text or ""):gsub("[%c|]", " "):gsub("[;=]", ","):gsub("^%s+", ""):gsub("%s+$", "") + if #text > MAX_NOTE then text = text:sub(1, MAX_NOTE) end + Codex.db.fav = Codex.db.fav or {} + local e = Codex.db.fav[key] + if text == "" then + if e then e.note = nil end + return true + end + if not e then + e = { t = time and time() or 0 } + Codex.db.fav[key] = e + end + e.note = text + return true +end + +------------------------------------------------------------------------ +-- Dev-Notizen: Notizen zu Eintraegen (Quest, Kreatur, Objekt, Item), nur mit dem Modul Freewood-OctoWoW-Dev und +-- einer Rolle mit Bearbeitungsrecht. Sie liegen lokal beim Entwickler und werden nicht geteilt. +------------------------------------------------------------------------ + +local MAX_DEVNOTE = 500 + +function Codex.DevNoteSupported(kind) return FAV_PREFIX[kind] ~= nil end + +-- Darf dieser Charakter Dev-Notizen sehen und schreiben? +function Codex.CanDevNote() + return FW.Dev ~= nil and Codex.Trust ~= nil and Codex.Trust.CanEdit() and true or false +end + +function Codex.GetDevNote(kind, id) + local key = FavKey(kind, id) + local e = key and Codex.db.dnotes and Codex.db.dnotes[key] + return e and e.n or nil +end + +function Codex.SetDevNote(kind, id, text) + local key = FavKey(kind, id) + if not key or not Codex.CanDevNote() then return false end + text = tostring(text or ""):gsub("[%c|]", " "):gsub("^%s+", ""):gsub("%s+$", "") + if #text > MAX_DEVNOTE then text = text:sub(1, MAX_DEVNOTE) end + Codex.db.dnotes = Codex.db.dnotes or {} + if text == "" then + Codex.db.dnotes[key] = nil + else + Codex.db.dnotes[key] = { n = text, t = time and time() or 0 } + end + return true +end + +function Codex.DevNoteCount() + local n = 0 + for _ in pairs(Codex.db.dnotes or {}) do n = n + 1 end + return n +end + +-- Austausch: eine Zeile "FWFAV1;q123=Notiz;u55=;i12=Notiz" (Eintraege ohne Notiz haben nach = nichts) +function Codex.FavExport() + local keys = {} + for key in pairs(Codex.db.fav or {}) do keys[#keys + 1] = key end + table.sort(keys) + local parts = { "FWFAV1" } + for _, key in ipairs(keys) do + local e = Codex.db.fav[key] + parts[#parts + 1] = key .. "=" .. (e.note or "") + end + return table.concat(parts, ";"), #keys +end + +-- Ergaenzt vorhandene Favoriten; bestehende Notizen bleiben. Rueckgabe: neu hinzugekommen, Notizen uebernommen, ungueltig +function Codex.FavImport(text) + text = tostring(text or ""):gsub("^%s+", ""):gsub("%s+$", "") + if text:sub(1, 6) ~= "FWFAV1" then return nil end + Codex.db.fav = Codex.db.fav or {} + local added, notes, bad = 0, 0, 0 + for item in text:sub(8):gmatch("[^;]+") do + local key, note = item:match("^([quio]%d+)=(.*)$") + if key then + local e = Codex.db.fav[key] + if not e then + e = { t = time and time() or 0 } + Codex.db.fav[key] = e + added = added + 1 + end + if note ~= "" and not e.note then + e.note = note:sub(1, 200) + notes = notes + 1 + end + else + bad = bad + 1 + end + end + return added, notes, bad +end diff --git a/CodexSeed.lua b/CodexSeed.lua new file mode 100644 index 0000000..95a13f2 --- /dev/null +++ b/CodexSeed.lua @@ -0,0 +1,708 @@ +-- Freewood-OctoWoW-Codex: Vorlage (Startdaten aus Freewood-OctoWoW-CodexData, Vanilla) +-- Das Datenaddon ist "LoadOnDemand" und wird erst beim ersten Zugriff geladen. Alles hier ist nur eine Vorlage: +-- eigene und geteilte Live-Daten gehen immer vor. Die Daten liegen als kurze Zeichenketten vor und werden erst +-- beim Zugriff entpackt (und gemerkt). +-- +-- Formate (siehe tools/pf2seed.lua): +-- Quest "Stufe|Mindeststufe|Rassen|Klassen|Beruf|Vorquests|Start|Abgabe|Ziele" (Refs: "U123.O55.I6497") +-- Kreatur "Stufe|Fraktion|Rang|Gebiet:Zelle,Zelle;Gebiet:Zelle" (Zelle = x*100+y in 1 %) +-- Item "u=,...;o=,...;v,..." +-- Rollen meta[rolle] = "id:wert,id:wert" (negative IDs sind Objekte) + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS.Codex +if not FW or not Codex then + return +end + +local Util = FW.Util +local GRID = Codex.GRID + +local Seed = {} +Codex.Seed = Seed + +local DATA_ADDON = "Freewood-OctoWoW-CodexData" +local S -- FreewoodCodexSeed, sobald geladen +local failed = false + +local floor = math.floor + +-- Rollen: Reihenfolge der Anzeige +Seed.ROLES = { + units = { "vendor", "repair", "flight", "innkeeper", "stablemaster", "spirithealer", "auctioneer", "battlemaster", "banker", "rares" }, + objects = { "mailbox", "meetingstone", "chests", "herbs", "mines", "fish" }, +} + +------------------------------------------------------------------------ +-- Laden +------------------------------------------------------------------------ + +function Seed.Load() + if S then + return true + end + if failed then + return false + end + local g = _G.FreewoodCodexSeed + if not (g and g.ready) then + local load = (C_AddOns and C_AddOns.LoadAddOn) or LoadAddOn + if type(load) == "function" then + pcall(load, DATA_ADDON) + end + g = _G.FreewoodCodexSeed + end + if g and g.ready then + S = g + return true + end + failed = true + return false +end + +-- Laedt bei Bedarf (nur die Oberflaeche ruft das auf, damit der Ladevorgang beim Oeffnen des Tabs geschieht) +function Seed.Disabled() + local db = Codex.db + return db and db.settings and db.settings.noSeed == true or false +end + +function Seed.Available() + if Seed.Disabled() then return false end + return Seed.Load() +end + +-- Nur pruefen, ob schon geladen ist, ohne zu laden (fuer Code, der waehrend des Spielens laeuft) +function Seed.Ready() + return S ~= nil and not Seed.Disabled() +end + +-- fuer Tests: Zustand zuruecksetzen +local caches +function Seed.Reset() + S, failed = nil, false + caches = { quest = {}, unit = {}, object = {}, item = {}, lists = {}, hay = {} } + Seed.nextIdx, Seed.rev, Seed.roleIdx, Seed.areaMap = nil, nil, nil, nil +end +Seed.Reset() + +local function Lang() + return Codex.Lang() +end + +------------------------------------------------------------------------ +-- Kleine Helfer +------------------------------------------------------------------------ + +local function Fields(s, n) + local out, pos = {}, 1 + for i = 1, n do + local e = s:find("|", pos, true) + if e then + out[i] = s:sub(pos, e - 1) + pos = e + 1 + else + out[i] = s:sub(pos) + pos = #s + 2 + end + end + return out +end + +local function Nums(s) + local list = {} + for n in (s or ""):gmatch("%d+") do + list[#list + 1] = tonumber(n) + end + return list +end + +local KIND_OF = { U = "units", O = "objects", I = "items" } + +local function Refs(s) + local list = {} + for k, id in (s or ""):gmatch("(%a)(%d+)") do + list[#list + 1] = { kind = KIND_OF[k], id = tonumber(id) } + end + return list +end + +local function AreaMap() + if Seed.areaMap then + return Seed.areaMap + end + local map = {} + for a, m in (S.areas or ""):gmatch("(%d+)=(%d+)") do + map[tonumber(a)] = tonumber(m) + end + Seed.areaMap = map + return map +end + +local function Pick(de, en) + if Lang() == "en" then + return en or de + end + return de or en +end + +------------------------------------------------------------------------ +-- Quests +------------------------------------------------------------------------ + +function Seed.Quest(id) + local c = caches.quest[id] + if c ~= nil then + return c or nil + end + if not Seed.Load() then + return nil + end + local s = S.q[id] + if not s then + caches.quest[id] = false + return nil + end + local f = Fields(s, 9) + local q = { + id = id, lvl = tonumber(f[1]), min = tonumber(f[2]), race = tonumber(f[3]), class = tonumber(f[4]), + skill = tonumber(f[5]), pre = Nums(f[6]), start = Refs(f[7]), stop = Refs(f[8]), obj = Refs(f[9]), + } + caches.quest[id] = q + return q +end + +function Seed.QuestTitle(id) + if not Seed.Load() then + return nil + end + return Pick(S.qd[id], S.qe[id]) +end + +-- Folgequests: alle Quests, die diese als Vorquest haben +function Seed.NextQuests(id) + if not Seed.Load() then + return {} + end + if not Seed.nextIdx then + local idx = {} + for qid, s in pairs(S.q) do + local pre = Fields(s, 6)[6] + for p in pre:gmatch("%d+") do + local n = tonumber(p) + local l = idx[n] + if not l then + l = {} + idx[n] = l + end + l[#l + 1] = qid + end + end + for _, l in pairs(idx) do + table.sort(l) + end + Seed.nextIdx = idx + end + return Seed.nextIdx[id] or {} +end + +-- Position in der Questkette: pos, total (zum Beispiel 2, 4), nil ausserhalb einer Kette. +-- Bei Verzweigungen zaehlt der laengste Weg vor und nach der Quest. +local chainMemo = {} +local function ChainLen(id, dir, depth) + local key = dir .. id + local c = chainMemo[key] + if c then + return c + end + if depth > 40 then + return 1 + end + local best = 0 + local list + if dir == "u" then + local q = Seed.Quest(id) + list = q and q.pre or {} + else + list = Seed.NextQuests(id) + end + for _, other in ipairs(list) do + if other ~= id then + best = math.max(best, ChainLen(other, dir, depth + 1)) + end + end + chainMemo[key] = best + 1 + return best + 1 +end + +function Seed.ChainPos(id) + if not Seed.Quest(id) then + return nil + end + local up, down = ChainLen(id, "u", 0), ChainLen(id, "d", 0) + if up + down <= 2 then + return nil + end + return up, up + down - 1 +end + +-- Stellung in einer Questkette: "start" (hat Folgequests, keine Vorquest), "mid", "end" (nur Vorquests) +-- oder nil (gehoert zu keiner Kette) +function Seed.ChainRole(id) + local q = Seed.Quest(id) + if not q then + return nil + end + -- manche Quests nennen sich selbst als Vorquest: das zaehlt nicht als Kette + local hasPre, hasNext = false, false + for _, p in ipairs(q.pre or {}) do + if p ~= id then hasPre = true break end + end + for _, n in ipairs(Seed.NextQuests(id)) do + if n ~= id then hasNext = true break end + end + if hasPre and hasNext then return "mid" end + if hasNext then return "start" end + if hasPre then return "end" end + return nil +end + +------------------------------------------------------------------------ +-- Kreaturen und Objekte +------------------------------------------------------------------------ + +local function EntityData(kind) + return kind == "units" and S.u or S.o +end + +-- Rueckgabe: { id, l1, l2, fac, rank, spots, cells } +function Seed.Entity(kind, id) + local cache = kind == "units" and caches.unit or caches.object + local c = cache[id] + if c ~= nil then + return c or nil + end + if not Seed.Load() then + return nil + end + local s = EntityData(kind)[id] + if not s then + cache[id] = false + return nil + end + local f = Fields(s, 4) + local l1, l2 = f[1]:match("^(%d+)%-?(%d*)$") + if l1 and l2 == "" then + l2 = l1 + end + local e = { + id = id, l1 = tonumber(l1), l2 = tonumber(l2), fac = f[2] ~= "" and f[2] or nil, + rank = tonumber(f[3]), spots = f[4], + } + cache[id] = e + return e +end + +function Seed.Name(kind, id) + if not Seed.Load() then + return nil + end + if kind == "units" then + return Pick(S.ud[id], S.ue[id]) + elseif kind == "objects" then + return Pick(S.od[id], S.oe[id]) + elseif kind == "items" then + return Pick(S.id[id], S.ie[id]) + end + return nil +end + +-- Zellen wie im Codex: { [Karte*GRID^2 + y*GRID + x] = 1 } +function Seed.Cells(kind, id) + local e = Seed.Entity(kind, id) + if not e then + return {} + end + if e.cells then + return e.cells + end + local cells = {} + local areas = AreaMap() + for area, list in e.spots:gmatch("(%d+):([%d,]+)") do + local m = areas[tonumber(area)] + if m then + for c in list:gmatch("%d+") do + c = tonumber(c) + local cx = math.min(GRID - 1, floor(c / 100) * 2 + 1) + local cy = math.min(GRID - 1, (c % 100) * 2 + 1) + cells[m * GRID * GRID + cy * GRID + cx] = 1 + end + end + end + e.cells = cells + return cells +end + +-- Erste Fundstelle: Karte, x, y (Anteile 0..1) +function Seed.FirstSpot(kind, id) + local e = Seed.Entity(kind, id) + if not e then + return nil + end + local area, list = e.spots:match("(%d+):([%d,]+)") + local m = area and AreaMap()[tonumber(area)] + local c = list and tonumber(list:match("%d+")) + if not (m and c) then + return nil + end + return m, (floor(c / 100) + 0.5) / 100, ((c % 100) + 0.5) / 100 +end + +-- Gebiet einer Kreatur / eines Objekts (Karten-ID der ersten Fundstelle) +function Seed.MapOf(kind, id) + local m = Seed.FirstSpot(kind, id) + return m +end + +-- Kleinster Abstand (Kartenanteil) einer Zellenmenge zum Spieler auf der aktuellen Karte, sonst nil +function Seed.Distance(cells, loc) + loc = loc or (Util.GetPlayerLocation and Util.GetPlayerLocation()) + if not (loc and loc.mapID and loc.x and loc.y) then + return nil + end + local best + local g2 = GRID * GRID + for key in pairs(cells or {}) do + if floor(key / g2) == loc.mapID then + local idx = key % g2 + local dx = ((idx % GRID) + 0.5) / GRID - loc.x + local dy = (floor(idx / GRID) + 0.5) / GRID - loc.y + local d = dx * dx + dy * dy + if not best or d < best then + best = d + end + end + end + return best and math.sqrt(best) or nil +end + +------------------------------------------------------------------------ +-- Items: Quellen +------------------------------------------------------------------------ + +-- Rueckgabe: { u = { { id, chance } }, o = { { id, chance } }, v = { haendlerID } } +function Seed.Item(id) + local c = caches.item[id] + if c ~= nil then + return c or nil + end + if not Seed.Load() then + return nil + end + local s = S.i[id] + if not s then + caches.item[id] = false + return nil + end + local it = { u = {}, o = {}, v = {} } + for part in s:gmatch("[^;]+") do + local tag, body = part:sub(1, 1), part:sub(2) + if tag == "u" or tag == "o" then + for k, v in body:gmatch("(%d+)=([%d%.]+)") do + local t = it[tag] + t[#t + 1] = { id = tonumber(k), chance = tonumber(v) } + end + elseif tag == "v" then + it.v = Nums(body) + end + end + caches.item[id] = it + return it +end + +function Seed.ItemHasVendor(id) + if not Seed.Load() then + return false + end + local s = S.i[id] + return s ~= nil and (s:find(";v", 1, true) ~= nil or s:sub(1, 1) == "v") +end + +local function Reverse() + if Seed.rev then + return Seed.rev + end + -- flache Listen { Item, Chance, Item, Chance ... } sparen Speicher + local rev = { u = {}, o = {}, v = {} } + for itemID, s in pairs(S.i) do + for part in s:gmatch("[^;]+") do + local tag, body = part:sub(1, 1), part:sub(2) + if tag == "u" or tag == "o" then + local t = rev[tag] + for k, v in body:gmatch("(%d+)=([%d%.]+)") do + local key = tonumber(k) + local l = t[key] + if not l then + l = {} + t[key] = l + end + l[#l + 1] = itemID + l[#l + 1] = tonumber(v) + end + elseif tag == "v" then + for n in body:gmatch("%d+") do + local key = tonumber(n) + local l = rev.v[key] + if not l then + l = {} + rev.v[key] = l + end + l[#l + 1] = itemID + end + end + end + end + Seed.rev = rev + return rev +end + +-- Beute einer Kreatur / eines Objekts laut Vorlage: { { item, chance (Prozent) } }, nach Chance sortiert +function Seed.Drops(kind, id) + if not Seed.Load() then + return {} + end + local flat = Reverse()[kind == "units" and "u" or "o"][id] + local list = {} + for i = 1, #(flat or {}), 2 do + list[#list + 1] = { item = flat[i], chance = flat[i + 1] } + end + table.sort(list, function(a, b) + if a.chance ~= b.chance then return a.chance > b.chance end + return a.item < b.item + end) + return list +end + +-- Waren eines Haendlers: { itemID } +function Seed.VendorItems(id) + if not Seed.Load() then + return {} + end + local list = {} + for i, itemID in ipairs(Reverse().v[id] or {}) do + list[i] = itemID + end + table.sort(list) + return list +end + +------------------------------------------------------------------------ +-- Quests je Kreatur / Objekt (Geber, Abgabe, Ziel) +------------------------------------------------------------------------ + +function Seed.QuestsOf(kind, id) + if not Seed.Load() then + return {}, {}, {} + end + if not Seed.questRev then + local rev = { give = {}, turn = {}, target = {} } + local function Add(t, key, qid) + local l = t[key] + if not l then + l = {} + t[key] = l + end + if l[#l] ~= qid then + l[#l + 1] = qid + end + end + for qid in pairs(S.q) do + local q = Seed.Quest(qid) + for _, r in ipairs(q.start) do + if r.kind ~= "items" then Add(rev.give, r.kind .. r.id, qid) end + end + for _, r in ipairs(q.stop) do + if r.kind ~= "items" then Add(rev.turn, r.kind .. r.id, qid) end + end + for _, r in ipairs(q.obj) do + if r.kind ~= "items" then Add(rev.target, r.kind .. r.id, qid) end + end + end + Seed.questRev = rev + end + local key = kind .. id + local rev = Seed.questRev + return rev.give[key] or {}, rev.turn[key] or {}, rev.target[key] or {} +end + +------------------------------------------------------------------------ +-- Rollen (Haendler, Flugmeister ...) +------------------------------------------------------------------------ + +local function RoleIndex() + if Seed.roleIdx then + return Seed.roleIdx + end + local idx = { units = {}, objects = {}, ids = {} } + for _, group in ipairs({ Seed.ROLES.units, Seed.ROLES.objects }) do + for _, role in ipairs(group) do + local list = {} + for k, v in (S.meta[role] or ""):gmatch("(%-?%d+):([^,]*)") do + local n = tonumber(k) + local kind = n > 0 and "units" or "objects" + n = math.abs(n) + local t = idx[kind][n] + if not t then + t = {} + idx[kind][n] = t + end + t[role] = v ~= "" and v or true + list[#list + 1] = { kind = kind, id = n } + end + idx.ids[role] = list + end + end + Seed.roleIdx = idx + return idx +end + +-- Rollen einer Kreatur / eines Objekts: { vendor = "H", herbs = 280, ... } oder nil +function Seed.Roles(kind, id) + if not Seed.Load() then + return nil + end + return RoleIndex()[kind][id] +end + +-- Rollen als Liste in fester Reihenfolge +function Seed.RoleList(kind, id) + local roles = Seed.Roles(kind, id) + if not roles then + return {} + end + local list = {} + for _, role in ipairs(Seed.ROLES[kind]) do + if roles[role] then + list[#list + 1] = role + end + end + return list +end + +function Seed.RoleIDs(role) + if not Seed.Load() then + return {} + end + return RoleIndex().ids[role] or {} +end + +-- Naechste Kreaturen / Objekte mit einer Rolle auf der aktuellen Karte: { { kind, id, dist, m, x, y } } +function Seed.Nearest(role, limit, faction) + local loc = Util.GetPlayerLocation and Util.GetPlayerLocation() + local list = {} + if not (loc and loc.mapID and loc.x and loc.y) or not Seed.Load() then + return list + end + local side = faction or (UnitFactionGroup and select(1, UnitFactionGroup("player"))) or nil + local letter = side == "Alliance" and "A" or side == "Horde" and "H" or nil + for _, ref in ipairs(Seed.RoleIDs(role)) do + local e = Seed.Entity(ref.kind, ref.id) + local roles = Seed.Roles(ref.kind, ref.id) + local value = roles and roles[role] + local fac = (type(value) == "string" and value:match("^[AH]+$")) or (e and e.fac) + if e and (not letter or not fac or fac:find(letter, 1, true)) then + local d = Seed.Distance(Seed.Cells(ref.kind, ref.id)) + if d then + local best, bm, bx, by + local g2 = GRID * GRID + for key in pairs(Seed.Cells(ref.kind, ref.id)) do + if floor(key / g2) == loc.mapID then + local idx = key % g2 + local x, y = ((idx % GRID) + 0.5) / GRID, (floor(idx / GRID) + 0.5) / GRID + local dd = (x - loc.x) ^ 2 + (y - loc.y) ^ 2 + if not best or dd < best then + best, bm, bx, by = dd, loc.mapID, x, y + end + end + end + list[#list + 1] = { kind = ref.kind, id = ref.id, dist = d, m = bm, x = bx, y = by } + end + end + end + table.sort(list, function(a, b) + if a.dist ~= b.dist then return a.dist < b.dist end + return a.id < b.id + end) + if limit then + for i = #list, limit + 1, -1 do + list[i] = nil + end + end + return list +end + +------------------------------------------------------------------------ +-- Listen fuer die Oberflaeche +------------------------------------------------------------------------ + +-- Alle IDs einer Art (sortiert) +function Seed.IDs(kind) + if not Seed.Load() then + return {} + end + local c = caches.lists[kind] + if c then + return c + end + local src + if kind == "quests" then src = S.q + elseif kind == "units" then src = S.u + elseif kind == "objects" then src = S.o + elseif kind == "items" then src = S.ie end + local list = {} + for id in pairs(src or {}) do + list[#list + 1] = id + end + if kind == "items" then + for id in pairs(S.i) do + if not S.ie[id] then list[#list + 1] = id end + end + end + table.sort(list) + caches.lists[kind] = list + return list +end + +-- Kleingeschriebene Namen (deutsch und englisch) zur Suche +function Seed.Haystack(kind, id) + local c = caches.hay[kind] + if not c then + c = {} + caches.hay[kind] = c + end + local h = c[id] + if h then + return h + end + local de, en + if kind == "quests" then de, en = S.qd[id], S.qe[id] + elseif kind == "units" then de, en = S.ud[id], S.ue[id] + elseif kind == "objects" then de, en = S.od[id], S.oe[id] + else de, en = S.id[id], S.ie[id] end + h = ((de or "") .. "\n" .. (en or "")):lower() + c[id] = h + return h +end + +function Seed.Count() + if not Seed.Load() then + return 0, 0, 0, 0 + end + local function n(t) + local c = 0 + for _ in pairs(t) do c = c + 1 end + return c + end + return n(S.q), n(S.u), n(S.o), n(S.i) +end diff --git a/CodexShare.lua b/CodexShare.lua new file mode 100644 index 0000000..37a5342 --- /dev/null +++ b/CodexShare.lua @@ -0,0 +1,2334 @@ +-- Freewood-OctoWoW-Codex: Datenschicht fuers Teilen +-- +-- * Eigene Datensaetze (Kreatur, Objekt, Quest, Erfahrungstabelle) werden bereinigt und mit einem +-- Aenderungsstand (upd) versehen. Der Stand liegt in db.sync.stamps und wird aus dem Inhalt +-- berechnet, so muss die Erfassung selbst nichts davon wissen. +-- * Empfangene Datensaetze liegen getrennt je Absender in db.remote und werden nie in die +-- eigenen Daten geschrieben. Ein neuer Stand desselben Absenders ersetzt den alten (idempotent). +-- * Share.View() liefert die Sicht fuer die Anzeige: eigene und fremde Daten zusammengerechnet. +-- Ohne fremde Daten ist das einfach Codex.db. +-- +-- Schluessel eines Datensatzes: "u123" Kreatur, "o45" Objekt, "q678" Quest, "x0" Erfahrung, "t0" Kampfdauer. + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +if not FW or not Codex then + return +end + +local Share = {} +Codex.Share = Share +NS.Share = Share + +local GRID = Codex.GRID or 200 +local MAX_SENDERS = 12 +local MAX_RECORDS = 2500 -- je Absender +local MAX_PATH_RECORDS = 60000 -- Wegenetz-Bloecke je Absender (nur der Stand wird gemerkt) +local MAX_CELLS_SHARED = 120 +local MAX_DROPS = 80 +local MAX_NAMES = 6000 +local SELF_IMPORT = "~import" + +local KIND_OF = { u = "units", o = "objects", q = "quests", i = "ied" } +local LETTER_OF = { unit = "u", object = "o", quest = "q", item = "i" } +local CLASSES = { elite = true, rare = true, rareelite = true, worldboss = true } +local XP_CLASSES = { normal = true, elite = true, rare = true, rareelite = true, worldboss = true } + +------------------------------------------------------------------------ +-- Pruefer (alles, was von aussen kommt, geht hier durch) +------------------------------------------------------------------------ + +local function Text(v, max) + if type(v) ~= "string" then + return nil + end + v = v:gsub("|[cC]%x%x%x%x%x%x%x%x", ""):gsub("|[rR]", ""):gsub("|T.-|t", ""):gsub("|H.-|h", "") + v = v:gsub("|", ""):gsub("%c", " ") + v = v:gsub("^%s+", ""):gsub("%s+$", "") + if v == "" then + return nil + end + if #v > max then + v = v:sub(1, max) + end + return v +end + +local function Int(v, lo, hi) + if type(v) ~= "number" or v ~= v or v == math.huge or v == -math.huge then + return nil + end + v = math.floor(v) + if v < lo or v > hi then + return nil + end + return v +end + +local function Frac(v) + if type(v) ~= "number" or v ~= v or v < 0 or v > 1 then + return nil + end + return math.floor(v * 10000 + 0.5) / 10000 +end + +local function Sample(s) + if type(s) ~= "table" then + return nil + end + local n, sum = Int(s.n, 1, 1000000), Int(s.sum, 0, 10000000000) + local lo, hi = Int(s.min, 0, 10000000), Int(s.max, 0, 10000000) + if not (n and sum and lo and hi) or lo > hi then + return nil + end + if sum < lo * n - 1 or sum > hi * n + 1 then + return nil + end + return { n = n, sum = sum, min = lo, max = hi } +end + +-- Tabelle { [schluessel] = zahl } mit Obergrenze; die groessten Werte bleiben +local function TopCounts(src, keyFn, valLo, valHi, limit) + local list = {} + local seen = 0 + for k, v in pairs(src) do + seen = seen + 1 + if seen > 20000 then + break + end + local key, val = keyFn(k), Int(v, valLo, valHi) + if key and val then + list[#list + 1] = { key, val } + end + end + table.sort(list, function(a, b) + if a[2] ~= b[2] then + return a[2] > b[2] + end + return a[1] < b[1] + end) + local out, count = {}, 0 + for _, e in ipairs(list) do + if count >= limit then + break + end + out[e[1]] = e[2] + count = count + 1 + end + return out, count +end + +local function DropTable(src, k) + if type(src) ~= "table" or not k then + return nil + end + local list = {} + local seen = 0 + for id, d in pairs(src) do + seen = seen + 1 + if seen > 2000 then + break + end + local iid = Int(id, 1, 10000000) + if iid and type(d) == "table" then + local n = Int(d.n, 1, 1000000) + if n and n <= k then + local q = Int(d.q, n, 100000000) or n + local qk = d.qi and Int(d.qk, n, k) or nil + list[#list + 1] = { id = iid, n = n, q = q, qi = d.qi and 1 or nil, qk = qk } + end + end + end + table.sort(list, function(a, b) + if a.n ~= b.n then + return a.n > b.n + end + return a.id < b.id + end) + local out, count = {}, 0 + for _, e in ipairs(list) do + if count >= MAX_DROPS then + break + end + out[e.id] = { n = e.n, q = e.q, qi = e.qi, qk = e.qk } + count = count + 1 + end + if count == 0 then + return nil + end + return out +end + +local function Ref(r) + if type(r) ~= "table" then + return nil + end + local k = r.k + if k ~= "npc" and k ~= "object" and k ~= "other" then + return nil + end + return { + k = k, id = Int(r.id, 0, 100000000), n = Text(r.n, 60), m = Int(r.m, 0, 100000), + x = Frac(r.x), y = Frac(r.y), + } +end + +local function RewardList(list) + if type(list) ~= "table" then + return nil + end + local out = {} + for i = 1, math.min(#list, 12) do + local e = list[i] + local id = type(e) == "table" and Int(e.id, 1, 10000000) + if id then + out[#out + 1] = { id = id, c = Int(e.c, 1, 1000) or 1 } + end + end + return #out > 0 and out or nil +end + +local function SrcKey(k) + if type(k) == "string" and k:match("^[uo]%d+$") and #k <= 12 then + return k + end + return nil +end + +local function ObjectiveList(list) + if type(list) ~= "table" then + return nil + end + local out = {} + for i = 1, math.min(#list, 12) do + local o = list[i] + if type(o) == "table" then + local ty = Text(o.ty, 16) + local e = { + ty = ty and ty:lower():gsub("[^a-z]", "") or nil, + name = Text(o.name, 80), txt = Text(o.txt, 100), + req = Int(o.req, 0, 100000), item = Int(o.item, 1, 10000000), + } + if e.ty == "" then e.ty = nil end + if type(o.ids) == "table" then + e.ids = TopCounts(o.ids, function(k) return Int(k, 1, 100000000) end, 1, 1000000, 12) + if next(e.ids) == nil then e.ids = nil end + end + out[i] = e + else + out[i] = {} + end + end + return #out > 0 and out or nil +end + +local function QuestItems(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for item, sources in pairs(src) do + local iid = Int(item, 1, 10000000) + if iid and type(sources) == "table" and count < 12 then + local s = TopCounts(sources, SrcKey, 1, 1000000, 12) + if next(s) ~= nil then + out[iid] = s + count = count + 1 + end + end + end + return count > 0 and out or nil +end + +local function XPTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for pl, byMl in pairs(src) do + local p = Int(pl, 1, 80) + if p and type(byMl) == "table" then + for ml, byCls in pairs(byMl) do + local m = Int(ml, 1, 130) + if m and type(byCls) == "table" then + for cls, s in pairs(byCls) do + if XP_CLASSES[cls] and count < 400 then + local sm = Sample(s) + if sm then + out[p] = out[p] or {} + out[p][m] = out[p][m] or {} + out[p][m][cls] = sm + count = count + 1 + end + end + end + end + end + end + end + return count > 0 and out or nil +end + +-- Kampfdauer: ttk[Klasse][Spielerstufe][Gegnerstufe - Spielerstufe] = { n, sum, min, max } (Zehntelsekunden) +local function TTKTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for cls, byPl in pairs(src) do + if type(cls) == "string" and cls:match("^%u+$") and #cls <= 16 and type(byPl) == "table" then + for pl, byDelta in pairs(byPl) do + local p = Int(pl, 1, 80) + if p and type(byDelta) == "table" then + for delta, s in pairs(byDelta) do + local d = Int(delta, -5, 5) + local sm = d and count < 1200 and Sample(s) + if sm then + out[cls] = out[cls] or {} + out[cls][p] = out[cls][p] or {} + out[cls][p][d] = sm + count = count + 1 + end + end + end + end + end + end + return count > 0 and out or nil +end + +-- Kampfdauer einer Kreatur: tt[Klasse][Spielerstufe] = { n, sum, min, max } (Zehntelsekunden) +local function UnitTTK(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for cls, byPl in pairs(src) do + if type(cls) == "string" and cls:match("^%u+$") and #cls <= 16 and type(byPl) == "table" then + for pl, s in pairs(byPl) do + local p = Int(pl, 1, 80) + local sm = p and count < 60 and Sample(s) + if sm then + out[cls] = out[cls] or {} + out[cls][p] = sm + count = count + 1 + end + end + end + end + return count > 0 and out or nil +end + +-- Rollen: nur bekannte Namen, Zaehler begrenzt +local ROLE_KEYS = { + vendor = true, repair = true, flight = true, innkeeper = true, stablemaster = true, spirithealer = true, + auctioneer = true, battlemaster = true, banker = true, classtrainer = true, proftrainer = true, mailbox = true, +} +local MAX_WARES_SHARED = 90 +local MAX_TRAIN_SHARED = 100 +local MAX_DEST_SHARED = 40 +local MAX_PRICES_SHARED = 4 + +local function RoleTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for role, n in pairs(src) do + local v = type(role) == "string" and ROLE_KEYS[role] and Int(n, 1, 100000) + if v then + out[role] = v + count = count + 1 + end + end + return count > 0 and out or nil +end + +-- { [preis in Kupfer] = Anzahl }, hoechstens MAX_PRICES_SHARED (haeufigste) +local function PriceMap(src) + if type(src) ~= "table" then + return nil + end + local out, count = TopCounts(src, function(k) + return Int(k, 0, 2000000000) + end, 1, 100000, MAX_PRICES_SHARED) + return count > 0 and out or nil +end + +local function SortedKeys(src, keyFn) + local list = {} + local seen = 0 + for k, v in pairs(src) do + seen = seen + 1 + if seen > 5000 then + break + end + local key = keyFn(k) + if key ~= nil and type(v) == "table" then + list[#list + 1] = { key, v } + end + end + table.sort(list, function(a, b) + return a[1] < b[1] + end) + return list +end + +local function WareTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for _, e in ipairs(SortedKeys(src, function(k) + return Int(k, 1, 10000000) + end)) do + if count >= MAX_WARES_SHARED then + break + end + local pr = PriceMap(e[2].pr) + if pr then + out[e[1]] = { pr = pr, st = Int(e[2].st, 2, 1000), lim = e[2].lim and 1 or nil, ec = e[2].ec and 1 or nil } + count = count + 1 + end + end + return count > 0 and out or nil +end + +local function TrainTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for _, e in ipairs(SortedKeys(src, function(k) + return Text(k, 100) + end)) do + if count >= MAX_TRAIN_SHARED then + break + end + local r = e[2] + local nm = Text(r.nm, 60) + local pr = PriceMap(r.pr) + if nm and pr then + out[e[1]] = { nm = nm, sp = Int(r.sp, 1, 10000000), rk = Text(r.rk, 30), lv = Int(r.lv, 1, 130), sn = Text(r.sn, 40), sr = Int(r.sr, 1, 1000), pr = pr } + count = count + 1 + end + end + return count > 0 and out or nil +end + +local function FlightTable(src) + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for _, e in ipairs(SortedKeys(src, function(k) + return Text(k, 60) + end)) do + if count >= MAX_DEST_SHARED then + break + end + local pr = PriceMap(e[2]) + if pr then + out[e[1]] = pr + count = count + 1 + end + end + return count > 0 and out or nil +end + +local function PerLevel(src, depth) + -- xm[spielerstufe][gegnerstufe] bzw. xp[spielerstufe] + if type(src) ~= "table" then + return nil + end + local out, count = {}, 0 + for pl, v in pairs(src) do + local p = Int(pl, 1, 80) + if p and type(v) == "table" then + if depth == 1 then + local sm = Sample(v) + if sm and count < 40 then + out[p] = sm + count = count + 1 + end + else + for ml, s in pairs(v) do + local m = Int(ml, 1, 130) + local sm = m and Sample(s) + if sm and count < 40 then + out[p] = out[p] or {} + out[p][m] = sm + count = count + 1 + end + end + end + end + end + return count > 0 and out or nil +end + +-- DEV-Funktionen: Namen und "fehlerhaft"-Markierung (bad: 1 = fehlerhaft, 0 = Markierung aufgehoben) +local function BadFlag(v) + if v == true or v == 1 then return 1 end + if v == false or v == 0 then return 0 end + return nil +end + +local function CanEditNames() + return Codex.Trust ~= nil and Codex.Trust.CanEdit() +end + +local BUILTIN = "~forever" -- die mitgelieferten Startdaten (Freewood-OctoWoW-ForeverData): wie ein Berechtigter, aber ganz unten + +local function IsDevSender(name) + if name == BUILTIN then return true end + return Codex.Trust ~= nil and Codex.Trust.IsDev(name) +end + +-- Eigenschaften (Zonen, Dungeon, Klasse, Stufen) von Berechtigten; auf der Leitung ist dg 1 / 0 +local function WireBool(v) + if v == true or v == 1 then return true end + if v == false or v == 0 then return false end + return nil +end + +local function SanitizeEd(src, kind) + if type(src) ~= "table" then return nil end + local probe = { z = src.z, dg = WireBool(src.dg), cl = src.cl, l1 = src.l1, l2 = src.l2, pre = src.pre, + key = src.key, nx = src.nx, bf = src.bf, af = src.af, ql = src.ql, tx = src.tx, si = src.si, qk = src.qk, qd = src.qd, qb = src.qb, qr = src.qr } + local ed = Codex.CleanEd(probe, kind) + if not ed then return nil end + if ed.dg ~= nil then ed.dg = ed.dg and 1 or 0 end + return ed +end + +-- Aus dem Datensatz eines anderen Spielers in die eigene Form (dg als Wahrheitswert) +local function EdFromWire(ed) + if type(ed) ~= "table" then return nil end + local out = Codex.CopyEd(ed) + out.dg = WireBool(ed.dg) + return out +end + +-- Texte je Teilfeld fuer das Protokoll +local function EdFields(ed) + local f = {} + if type(ed) ~= "table" then return f end + if ed.z then f.ez = table.concat(ed.z, ",") end + local dg = WireBool(ed.dg) + if dg ~= nil then f.edg = dg and "1" or "0" end + f.ecl = ed.cl + if ed.l1 then f.el = ed.l1 .. "-" .. (ed.l2 or ed.l1) end + if ed.pre then f.epre = table.concat(ed.pre, ",") end + if ed.key then f.ekey = tostring(ed.key) end + if ed.nx then f.enx = table.concat(ed.nx, ",") end + if ed.bf then f.ebf = table.concat(ed.bf, ",") end + if ed.af then f.eaf = table.concat(ed.af, ",") end + if ed.ql then f.eql = tostring(ed.ql) end + if ed.tx then f.etx = ed.tx end + if ed.si then f.esi = tostring(ed.si) end + if ed.qk then f.eqk = table.concat(ed.qk, ",") end + if ed.qd then f.eqd = table.concat(ed.qd, ",") end + if ed.qb then f.eqb = table.concat(ed.qb, ",") end + if ed.qr then f.eqr = table.concat(ed.qr, ",") end + return f +end +Share.EdFields = EdFields +-- alle Teilfelder der Eigenschaften (fuer Vorschau und Protokoll) +Share.ED_FIELDS = { "ez", "edg", "ecl", "el", "epre", "ekey", "enx", "ebf", "eaf", "eql", "etx", "esi", "eqk", "eqd", "eqb", "eqr" } + +-- Instanzen, in denen eine Kreatur / ein Objekt gesehen wurde: { [instanceID] = Anzahl }, hoechstens 3 +local function SanitizeIz(src) + if type(src) ~= "table" then return nil end + local out, count = {}, 0 + for id, n in pairs(src) do + local iid, cnt = Int(id, 1, 100000), Int(n, 1, 1000000) + if iid and cnt and count < 3 then + out[iid] = cnt + count = count + 1 + end + end + return count > 0 and out or nil +end + +local function SanitizeEntity(rec, isObject) + local out = {} + out.n = Text(rec.n, 60) + out.cde, out.cen = Text(rec.cde, 60), Text(rec.cen, 60) + out.bad = BadFlag(rec.bad) + out.ed = SanitizeEd(rec.ed, isObject and "objects" or "units") + out.iz = SanitizeIz(rec.iz) + if not isObject then + out.l1, out.l2 = Int(rec.l1, 1, 130), Int(rec.l2, 1, 130) + if out.l1 and out.l2 and out.l1 > out.l2 then + out.l1, out.l2 = out.l2, out.l1 + end + out.c = CLASSES[rec.c] and rec.c or nil + out.f = Text(rec.f, 40) + end + out.k = Int(rec.k, 0, 1000000) + local k = out.k or 0 + if k > 0 then + out.coinN = Int(rec.coinN, 0, k) + out.coin = out.coinN and out.coinN > 0 and Int(rec.coin, 0, 1000000000000) or nil + if not out.coin then + out.coinN = nil + end + end + if type(rec.cells) == "table" then + local cells, count = TopCounts(rec.cells, function(key) + return Int(key, 0, 1000000000) + end, 1, 1000000, MAX_CELLS_SHARED) + if count > 0 then + out.cells = cells + end + end + out.d = DropTable(rec.d, k) + if type(rec.alt) == "table" then + local ak = Int(rec.alt.k, 1, 1000000) + if ak then + out.alt = { k = ak, d = DropTable(rec.alt.d, ak) } + end + end + out.ro = RoleTable(rec.ro) + if not isObject then + out.xm = PerLevel(rec.xm, 2) + out.tt = UnitTTK(rec.tt) + out.ti = Text(rec.ti, 40) + out.wa = WareTable(rec.wa) + out.tr = TrainTable(rec.tr) + if type(rec.trc) == "string" and Codex.CLASS_TOKENS and Codex.CLASS_TOKENS[rec.trc] then out.trc = rec.trc end + out.fl = FlightTable(rec.fl) + out.fn = Text(rec.fn, 60) + out.ct = (type(rec.ct) == "string" and Codex.CT_CODES and Codex.CT_CODES[rec.ct]) and rec.ct or nil + out.ne = Text(rec.ne, 60) + end + if type(rec.nm) == "table" then + local nm, count = {}, 0 + local function Wanted(id) + return (out.d and out.d[id]) or (out.alt and out.alt.d and out.alt.d[id]) + end + for id, name in pairs(rec.nm) do + local iid = Int(id, 1, 10000000) + local t = iid and Wanted(iid) and Text(name, 80) + if t and count < MAX_DROPS then + nm[iid] = t + count = count + 1 + end + end + if count > 0 then + out.nm = nm + end + end + if not (out.n or (out.k and out.k > 0) or out.cells or out.iz or out.cde or out.cen or out.bad or out.ed or out.tt or out.xm or out.ro or out.wa or out.tr or out.fl) then + return nil + end + return out +end + +-- Items: nur die Eigenschaften von Berechtigten +local function SanitizeItem(rec, id) + local out = { ed = SanitizeEd(rec.ed, (id and id >= Codex.ZONE_BASE) and "zones" or "items") } + if not out.ed then return nil end + return out +end + +local QTEXT_CAP = { d = 900, o = 400, p = 400, r = 600 } +local function QuestTexts(tx) + if type(tx) ~= "table" then return nil end + local out, any = {}, false + for f, cap in pairs(QTEXT_CAP) do + local v = Text(tx[f], cap) + if v and #v >= 3 then + out[f] = v + any = true + end + end + return any and out or nil +end + +local function SanitizeQuest(rec) + local out = {} + out.t = Text(rec.t, 100) + if not out.t then + return nil + end + out.cde, out.cen = Text(rec.cde, 100), Text(rec.cen, 100) + out.bad = BadFlag(rec.bad) + out.ed = SanitizeEd(rec.ed, "quests") + out.lvl, out.pl = Int(rec.lvl, 0, 130), Int(rec.pl, 0, 130) + out.zone = Int(rec.zone, 0, 100000) + out.give, out.turn = Ref(rec.give), Ref(rec.turn) + out.si = Int(rec.si, 1, 100000000) + -- Kette und Voraussetzungen: Vorquests (jede genuegt), Mindeststufe, Volks- und Klassenmaske + if type(rec.pre) == "table" then + local pre = {} + for i = 1, math.min(#rec.pre, 8) do + local v = Int(rec.pre[i], 1, 100000000) + if v then pre[#pre + 1] = v end + end + if #pre > 0 then out.pre = pre end + end + out.mn, out.rc, out.cl = Int(rec.mn, 1, 130), Int(rec.rc, 1, 4294967295), Int(rec.cl, 1, 4294967295) + out.ev = Int(rec.ev, 1, 255) -- Event-Nummer (Weltevent): nur Event-Quests plant der Levelplaner nur auf Wunsch + -- Beobachtungen zur Verfuegbarkeit: pi (Kandidat -> Anzahl), po (Beobachtungen), mi (Stufenbereich) + if type(rec.pi) == "table" then + local pi, count = {}, 0 + for k, v in pairs(rec.pi) do + local id, n = Int(k, 1, 100000000), Int(v, 1, 100000) + if id and n and count < 8 then pi[id] = n count = count + 1 end + end + local po = Int(rec.po, 1, 100000) + if count > 0 and po then out.pi, out.po = pi, po end + end + if type(rec.mi) == "table" then + local lo, hi, n = Int(rec.mi.lo, 0, 130), Int(rec.mi.hi, 1, 130), Int(rec.mi.n, 1, 100000) + if lo and hi and n and hi > lo then out.mi = { lo = lo, hi = hi, n = n } end + end + out.xp, out.money = Int(rec.xp, 0, 10000000), Int(rec.money, 0, 1000000000) + if type(rec.rep) == "table" then + local rep, count = {}, 0 + for name, v in pairs(rec.rep) do + local nm, val = Text(name, 60), Int(v, -100000, 100000) + if nm and val and count < 8 then + rep[nm] = val + count = count + 1 + end + end + if count > 0 then + out.rep = rep + end + end + out.fixed, out.choice = RewardList(rec.fixed), RewardList(rec.choice) + out.tm, out.tr, out.ta = Sample(rec.tm), Sample(rec.tr), Sample(rec.ta) + out.obj = ObjectiveList(rec.obj) + out.qi = QuestItems(rec.qi) + out.tx = QuestTexts(rec.tx) + return out +end + +local function SanitizeXPRecord(rec) + local xp = XPTable(rec.xp) + if not xp then + return nil + end + return { xp = xp } +end + +local function SanitizeTTKRecord(rec) + local tk = TTKTable(rec.tk) + if not tk then + return nil + end + return { tk = tk } +end + +-- Verkehrsverbindung (Schiff, Zeppelin, ...): von Berechtigten erfasst +local function SanitizeTransportRecord(rec) + local T = Codex.Transport + local kindOk = false + for _, k in ipairs(T and T.KINDS or {}) do + if rec.k == k then kindOk = true end + end + if not kindOk then return nil end + local function Spot(p) + if type(p) ~= "table" then return nil end + local m, x, y = Int(p.m, -1000000, 1000000), Frac(p.x), Frac(p.y) + if not (m and x and y) then return nil end + return { m = m, x = x, y = y } + end + local a, b = Spot(rec.a), Spot(rec.b) + local t = Int(rec.t, 1, 7200) + if not (a and b and t) then return nil end + local out = { k = rec.k, t = t, a = a, b = b, two = rec.two ~= false and true or false } + if rec.f == "A" or rec.f == "H" then out.f = rec.f end + out.n = Text(rec.n, 40) + if rec.del == true then out.del = true end + return out +end + +-- Ortspunkt (Instanz-Eingang, Berg-Aufstieg, ...): von Berechtigten erfasst +local function SanitizeSpotRecord(rec) + local S = Codex.Spots + local kindOk = false + for _, k in ipairs(S and S.KINDS or {}) do + if rec.k == k then kindOk = true end + end + if not kindOk or type(rec.p) ~= "table" then return nil end + local m, x, y = Int(rec.p.m, -1000000, 1000000), Frac(rec.p.x), Frac(rec.p.y) + if not (m and x and y) then return nil end + local out = { k = rec.k, p = { m = m, x = x, y = y }, q = {} } + if type(rec.q) == "table" then + local seen = {} + for _, v in ipairs(rec.q) do + local id = Int(v, 1, 1000000) + if id and not seen[id] and #out.q < (S and S.MAX_QUESTS or 20) then + seen[id] = true + out.q[#out.q + 1] = id + end + end + table.sort(out.q) + end + out.am = Int(rec.am, -1000000, 1000000) + if rec.f == "A" or rec.f == "H" then out.f = rec.f end + out.n = Text(rec.n, 40) + if rec.del == true then out.del = true end + if not out.am and #out.q == 0 and not out.del then return nil end + return out +end + +-- Flugroute: Dauer, Preis, Strecke (Weltpunkte) und die Namen der beiden Flugpunkte +local function SanitizeTaxiNode(n) + if type(n) ~= "table" then return nil end + local out = { n = Text(n.n, 60), a = Int(n.a, -1000000, 1000000), b = Int(n.b, -1000000, 1000000) } + local m, x, y = Int(n.m, 1, 1000000), Frac(n.x), Frac(n.y) + if m and x and y then out.m, out.x, out.y = m, x, y end + return out +end + +local function SanitizeTaxiRecord(rec) + local t, c = Int(rec.t, 5, 7200), Int(rec.c, 0, 100000000) + if not t or not c or type(rec.p) ~= "table" then return nil end + local cnt = #rec.p + if cnt < 4 or cnt > 80 or cnt % 2 ~= 0 then return nil end + local p = {} + for i = 1, cnt do + local v = Int(rec.p[i], -1000000, 1000000) + if not v then return nil end + p[i] = v + end + local A, B = SanitizeTaxiNode(rec.A), SanitizeTaxiNode(rec.B) + if not A or not B then return nil end + return { t = t, c = c, n = Int(rec.n or 1, 1, 100000) or 1, p = p, A = A, B = B } +end + +-- Wegenetz-Block: Text "Zelle.Maske,..." wird geprueft und in Normalform gebracht +local function SanitizePathRecord(rec) + local P = Codex.Path + local cells = P and P.ParseChunk(rec.c) + if not cells then + return nil + end + return { c = P.ChunkText(cells) } +end + +local function SanitizeLinkRecord(rec) + local P = Codex.Path + local list = P and P.ParseLinks(rec.l) + if not list then + return nil + end + return { l = list } +end + +-- key: "u123"; rec: unbekannte Eingabe. Rueckgabe: bereinigter Datensatz { y, id, upd, ... } oder nil +function Share.Sanitize(key, rec, upd) + if type(key) ~= "string" or type(rec) ~= "table" then + return nil + end + local letter, num = key:match("^([uoqixtpzghf])(%d+)$") + local id = num and Int(tonumber(num), 0, (letter == "f") and 100000000000000 or (letter == "p") and 65536000000 or 100000000) + if not letter or not id then + return nil + end + upd = Int(upd, 1, 4000000000) + if not upd then + return nil + end + local out + if letter == "u" then + out = SanitizeEntity(rec, false) + elseif letter == "o" then + out = SanitizeEntity(rec, true) + elseif letter == "q" then + out = SanitizeQuest(rec) + elseif letter == "i" then + out = SanitizeItem(rec, id) + elseif letter == "t" then + out = SanitizeTTKRecord(rec) + elseif letter == "p" then + out = SanitizePathRecord(rec) + elseif letter == "z" then + out = SanitizeLinkRecord(rec) + elseif letter == "g" then + out = SanitizeTransportRecord(rec) + elseif letter == "h" then + out = SanitizeSpotRecord(rec) + elseif letter == "f" then + out = SanitizeTaxiRecord(rec) + else + out = SanitizeXPRecord(rec) + end + if not out then + return nil + end + out.y, out.id, out.upd = letter, id, upd + return out +end + +------------------------------------------------------------------------ +-- Eigene Datensaetze und ihr Stand +------------------------------------------------------------------------ + +local function Store() + local db = Codex.db + db.sync = db.sync or {} + db.sync.stamps = db.sync.stamps or {} + db.sync.sent = db.sync.sent or 0 + db.sync.received = db.sync.received or 0 + return db.sync +end +Share.Store = Store + +local function Sum(t) + local s = 0 + for _, v in pairs(t) do + s = s + (type(v) == "table" and (v.n or 0) or v) + end + return s +end + +local function BucketSig(b) + local s = (b.k or 0) * 17 + for _, d in pairs(b.d or {}) do + s = s + (d.n or 0) * 13 + 1 + end + return s +end + +local function StrSig(str) + if type(str) ~= "string" then + return 0 + end + local h = #str + for i = 1, math.min(#str, 100) do + h = (h * 31 + str:byte(i)) % 1000003 + end + return h +end + +local function EdSig(ed) + if type(ed) ~= "table" then return 0 end + local h = 0 + for k, v in pairs(EdFields(ed)) do + h = h + StrSig(k) * 3 + StrSig(tostring(v)) * 59 + end + return h +end + +local function Signature(letter, e) + local s + if letter == "i" then + return (1 + EdSig(e.ed)) % 2147483647 + elseif letter == "q" then + s = (e.seen or 0) * 3 + (e.done or 0) * 5 + #(e.t or "") * 7 + (e.xp or 0) + (e.money or 0) + s = s + #(e.fixed or {}) * 11 + #(e.choice or {}) * 13 + (e.partial and 1 or 0) + s = s + (e.tm and e.tm.n or 0) * 23 + (e.tr and e.tr.n or 0) * 29 + (e.ta and e.ta.n or 0) * 31 + for i, o in ipairs(e.obj or {}) do + s = s + #(o.name or "") * (i + 1) + (o.item or 0) + Sum(o.ids or {}) * 3 + 1 + end + for item, srcs in pairs(e.qi or {}) do + s = s + item % 997 + Sum(srcs) + end + if e.give then s = s + (e.give.id or 0) end + if e.turn then s = s + (e.turn.id or 0) end + s = s + (e.si or 0) % 1019 + for _, p in ipairs(e.pre or {}) do s = s + p % 997 * 3 + 1 end + s = s + (e.mn or 0) * 5 + (e.rc or 0) % 1009 + (e.cl or 0) % 1013 + if type(e.tx) == "table" then + for _, v in pairs(e.tx) do s = s + #tostring(v) * 3 + 1 end + end + for p2, n2 in pairs(e.pi or {}) do s = s + p2 % 991 * n2 end + s = s + (e.po or 0) * 3 + (e.mi and (e.mi.lo * 7 + e.mi.hi * 11 + (e.mi.n or 0)) or 0) + if CanEditNames() then s = s + StrSig(e.cde) * 17 + StrSig(e.cen) * 19 + (BadFlag(e.bad) or 7) * 53 + EdSig(e.ed) end + else + s = BucketSig(e) + Sum(e.coinN and { e.coinN } or {}) * 5 + #(e.n or "") * 7 + (e.l1 or 0) + (e.l2 or 0) * 11 + (e.ct and #e.ct * 37 or 0) + (e.ne and #e.ne * 41 or 0) + if CanEditNames() then s = s + StrSig(e.cde) * 17 + StrSig(e.cen) * 19 + (BadFlag(e.bad) or 7) * 53 + EdSig(e.ed) end + s = s + Sum(e.iz or {}) * 61 + for _ in pairs(e.cells or {}) do + s = s + 1 + end + s = s + Sum(e.cells or {}) + if e.alt then + s = s + BucketSig(e.alt) * 3 + end + for _, byMl in pairs(e.xm or {}) do + s = s + Sum(byMl) * 19 + end + for _, byPl in pairs(e.tt or {}) do + for _, st in pairs(byPl) do + s = s + (st.n or 0) * 37 + (st.sum or 0) % 101 + end + end + s = s + Sum(e.ro or {}) * 41 + StrSig(e.ti) * 43 + StrSig(e.fn) * 47 + StrSig(e.trc) * 53 + for id, w in pairs(e.wa or {}) do + s = s + id % 991 + Sum(w.pr or {}) * 3 + (w.st or 0) + for price in pairs(w.pr or {}) do + s = s + price % 997 + end + end + for key, r in pairs(e.tr or {}) do + s = s + #key + (r.sp or 0) % 997 + (r.lv or 0) * 5 + (r.sr or 0) + Sum(r.pr or {}) * 3 + for price in pairs(r.pr or {}) do + s = s + price % 997 + end + end + for dest, pr in pairs(e.fl or {}) do + s = s + #dest * 7 + Sum(pr) * 3 + for price in pairs(pr) do + s = s + price % 997 + end + end + end + return s % 2147483647 +end + +local function IsShareable(letter, e) + if type(e) ~= "table" then + return false + end + if letter == "q" then + return type(e.t) == "string" and not e.loc -- eigene Quests des Creators bleiben bei dir + end + if letter == "i" then + return CanEditNames() and e.ed ~= nil + end + -- auch ohne Namen (zum Beispiel in Instanzen gelootete Kreaturen); Namen kommen von anderen Spielern + -- auch mit nur einem von Hand eingetragenen Namen + -- (auch Kreaturen, bei denen nur Kampfdauer oder Erfahrung gemessen wurde) + local named = CanEditNames() and (e.cde ~= nil or e.cen ~= nil or e.bad ~= nil or e.ed ~= nil) + return ((e.k or 0) > 0) or e.cells ~= nil or e.iz ~= nil or named or e.tt ~= nil or e.xm ~= nil + or e.ro ~= nil or e.wa ~= nil or e.tr ~= nil or e.fl ~= nil +end + +Share.IsShareable = IsShareable + +local function XPSignature() + local s, any = 0, false + for _, byMl in pairs(Codex.db.xp or {}) do + for _, byCls in pairs(byMl) do + for _, sm in pairs(byCls) do + s = s + (sm.n or 0) * 7 + 1 + any = true + end + end + end + return any and s % 2147483647 or nil +end + +local function TTKSignature() + local s, any = 0, false + for _, byPl in pairs(Codex.db.ttk or {}) do + for _, byDelta in pairs(byPl) do + for _, sm in pairs(byDelta) do + s = s + (sm.n or 0) * 7 + 1 + any = true + end + end + end + return any and s % 2147483647 or nil +end + +local scanCache, scanAt = nil, 0 +local firstScanDone = false + +local function Touch(stamps, key, sig, now) + local s = stamps[key] + if not s then + stamps[key] = { sig = sig, upd = now } + return true + elseif s.sig ~= sig then + s.sig = sig + s.upd = math.max(now, (s.upd or 0) + 1) + return true + end + return false +end + +-- Alle eigenen Datensaetze durchgehen. Rueckgabe: Liste { key, upd }, Anzahl, Pruefwert, geaenderte Schluessel +function Share.Scan(force) + local now = time() + if scanCache and not force and now - scanAt < 30 then + return scanCache.list, scanCache.count, scanCache.hash, {} + end + local store = Store() + local stamps = store.stamps + local list, changed, hash = {}, {}, 0 + for letter, field in pairs(KIND_OF) do + for id, e in pairs(Codex.db[field]) do + if type(id) == "number" and IsShareable(letter, e) then + local key = letter .. id + if Touch(stamps, key, Signature(letter, e), now) then + changed[#changed + 1] = key + end + local upd = stamps[key].upd + list[#list + 1] = { key = key, upd = upd } + hash = (hash + (id % 100003) * 31 + upd % 1000003 + (letter:byte() * 7)) % 2147483647 + end + end + end + local xs = XPSignature() + if xs then + if Touch(stamps, "x0", xs, now) then + changed[#changed + 1] = "x0" + end + list[#list + 1] = { key = "x0", upd = stamps.x0.upd } + hash = (hash + stamps.x0.upd % 1000003 + 5) % 2147483647 + end + local ts = TTKSignature() + if ts then + if Touch(stamps, "t0", ts, now) then + changed[#changed + 1] = "t0" + end + list[#list + 1] = { key = "t0", upd = stamps.t0.upd } + hash = (hash + stamps.t0.upd % 1000003 + 11) % 2147483647 + end + for id, rec in pairs(Codex.db.transport or {}) do + if type(id) == "number" and type(rec) == "table" and rec.upd then + list[#list + 1] = { key = "g" .. id, upd = rec.upd } + hash = (hash + id % 100003 * 17 + rec.upd % 1000003 + 103) % 2147483647 + end + end + for id, rec in pairs(Codex.db.spots or {}) do + if type(id) == "number" and type(rec) == "table" and rec.upd then + list[#list + 1] = { key = "h" .. id, upd = rec.upd } + hash = (hash + id % 100003 * 19 + rec.upd % 1000003 + 107) % 2147483647 + end + end + if Codex.Taxi and Codex.Taxi.SyncList then + for _, e in ipairs(Codex.Taxi.SyncList()) do + list[#list + 1] = e + hash = (hash + (tonumber(e.key:sub(2)) or 0) % 100003 * 23 + e.upd % 1000003 + 109) % 2147483647 + end + end + if Codex.Path and Codex.Path.SyncList then + for _, e in ipairs(Codex.Path.SyncList()) do + list[#list + 1] = e + hash = (hash + (tonumber(e.key:sub(2)) or 0) % 100003 * 13 + e.upd % 1000003 + e.key:byte() * 3) % 2147483647 + end + end + scanCache = { list = list, count = #list, hash = hash } + scanAt = now + if not firstScanDone then + firstScanDone = true + changed = {} + end + return list, #list, hash, changed +end + +function Share.Summary() + local _, count, hash = Share.Scan(false) + return count, hash +end + +function Share.InvalidateScan() + scanCache = nil +end + +-- Stand eines einzelnen eigenen Datensatzes neu berechnen. Rueckgabe: key, upd, geaendert +function Share.StampOne(kind, id) + local letter = LETTER_OF[kind] + if not letter or type(id) ~= "number" then + return nil + end + local e = Codex.db[KIND_OF[letter]][id] + if not IsShareable(letter, e) then + return nil + end + local key = letter .. id + local stamps = Store().stamps + local changed = Touch(stamps, key, Signature(letter, e), time()) + scanCache = nil + return key, stamps[key].upd, changed +end + +function Share.OwnUpd(key) + local s = Store().stamps[key] + return s and s.upd or nil +end + +-- Verzeichnis: eigene Datensaetze mit Stand > since, aelteste zuerst +function Share.Directory(since, limit) + local list = Share.Scan(false) + local out = {} + for _, e in ipairs(list) do + if e.upd > (since or 0) then + out[#out + 1] = e + end + end + table.sort(out, function(a, b) + if a.upd ~= b.upd then + return a.upd < b.upd + end + return a.key < b.key + end) + while #out > (limit or 400) do + out[#out] = nil + end + return out +end + +-- Eigener Datensatz zum Senden (bereinigt). Rueckgabe: record, upd +function Share.OwnRecord(key) + local letter, num = tostring(key):match("^([uoqixtpzghf])(%d+)$") + if not letter then + return nil + end + local id = tonumber(num) + if letter == "g" then + local rec = Codex.db.transport and Codex.db.transport[id] + if type(rec) ~= "table" or not rec.upd then return nil end + local out = Share.Sanitize(key, rec, rec.upd) + return out, rec.upd + end + if letter == "h" then + local rec = Codex.db.spots and Codex.db.spots[id] + if type(rec) ~= "table" or not rec.upd then return nil end + local out = Share.Sanitize(key, rec, rec.upd) + return out, rec.upd + end + if letter == "f" then + local T = Codex.Taxi + local raw, upd = T and T.RouteRecord(id) + if not raw then return nil end + return Share.Sanitize(key, raw, upd), upd + end + if letter == "p" or letter == "z" then + local P = Codex.Path + if not P then return nil end + local list = Share.Scan(false) + local upd + for _, e in ipairs(list) do + if e.key == key then upd = e.upd break end + end + if not upd then return nil end + local raw = letter == "p" and P.ChunkRecord(id) or P.LinkRecord() + if not raw then return nil end + local rec = Share.Sanitize(key, raw, upd) + return rec, upd + end + local upd = Share.OwnUpd(key) + if not upd then + Share.Scan(true) + upd = Share.OwnUpd(key) + end + if not upd then + return nil + end + local raw + if letter == "x" then + raw = { xp = Codex.db.xp } + elseif letter == "t" then + raw = { tk = Codex.db.ttk } + else + local e = Codex.db[KIND_OF[letter]][id] + if not IsShareable(letter, e) then + return nil + end + raw = e + if letter == "u" or letter == "o" then + local nm = {} + for _, bucket in ipairs({ e, e.alt or {} }) do + for itemID in pairs(bucket.d or {}) do + local name = Codex.db.names[itemID] + if name then + nm[itemID] = name + end + end + end + raw = setmetatable({ nm = nm }, { __index = e }) + end + end + local rec = Share.Sanitize(key, raw, upd) + -- Namen und Markierungen verschicken nur Berechtigte (mit Modul Freewood-OctoWoW-Dev) + if rec and (letter == "u" or letter == "o" or letter == "q" or letter == "i") and not CanEditNames() then + rec.cde, rec.cen, rec.bad, rec.ed = nil, nil, nil, nil + end + return rec, upd +end + +-- Zu grosse Datensaetze verkleinern (Waren, Lehrstoff, Flugziele halbieren, kleinste Schluessel bleiben) +function Share.Shrink(rec) + if type(rec) ~= "table" then + return nil + end + local out, cut = {}, false + for k, v in pairs(rec) do + out[k] = v + end + if rec.tx then -- zu gross: zuerst die Quest-Texte weglassen + out.tx = nil + cut = true + end + for _, field in ipairs({ "wa", "tr", "fl" }) do + local src = rec[field] + if type(src) == "table" then + local keys = {} + for key in pairs(src) do + keys[#keys + 1] = key + end + table.sort(keys, function(a, b) + if type(a) == type(b) then return a < b end + return type(a) == "number" + end) + if #keys > 8 then + local kept = {} + for i = 1, math.floor(#keys / 2) do + kept[keys[i]] = src[keys[i]] + end + out[field] = kept + cut = true + end + end + end + if not cut then + return nil + end + return out +end + +------------------------------------------------------------------------ +-- Fremde Daten +------------------------------------------------------------------------ + +local view, index, allDirty, dirtyIds, xpRecs, xpDirty = nil, nil, true, {}, {}, false +local lastXPBuild = -1000 -- Zeit des letzten Neubaus der XP- und Kampfzeit-Sicht (hoechstens alle 20 s) +local ttkRecs = {} +local nameCount + +local function Remote() + local db = Codex.db + db.remote = db.remote or {} + return db.remote +end + +function Share.RemoteStats() + local senders, records = 0, 0 + for _, s in pairs(Remote()) do + senders = senders + 1 + records = records + (s.n or 0) + end + return senders, records +end + +-- Diagnose (/fwcodex share): empfangene Datensaetze je Absender und Art (u o q i x t p z g h f), Dev-Status, eigene Datensaetze je Art +function Share.RemoteInfo() + local out = {} + for name, s in pairs(Remote()) do + local by = {} + for _, rec in pairs(s.rec) do by[rec.y or "?"] = (by[rec.y or "?"] or 0) + 1 end + out[#out + 1] = { name = name, dev = IsDevSender(name) and true or false, n = s.n or 0, np = s.np or 0, by = by } + end + return out +end + +function Share.OwnInfo() + local by = {} + for _, e in ipairs(Share.Scan(false) or {}) do + local l = tostring(e.key):sub(1, 1) + by[l] = (by[l] or 0) + 1 + end + return by +end + +-- Mitgelieferte Startdaten (nur im Speicher, nie gespeichert und nie gesendet): Schluessel -> Datensatz +local builtin = { rec = {}, n = 0 } + +function Share.HasBuiltin() + return builtin.n > 0 +end + +function Share.BuiltinCount() + return builtin.n +end + +-- Woher stammt ein Datensatz? "own" = eigene Beobachtung, "remote" = von anderen Spielern, "start" = nur aus den Startdaten +function Share.Origin(letter, id) + local field = KIND_OF[letter] + if not field or type(id) ~= "number" then return nil end + local own = Codex.db and Codex.db[field] and Codex.db[field][id] + if type(own) == "table" then return "own" end + local key = letter .. id + for _, s in pairs(Remote()) do + if s.rec and s.rec[key] then return "remote" end + end + if builtin.rec[key] then return "start" end + return nil +end + +function Share.HasRemote() + return next(Remote()) ~= nil or builtin.n > 0 +end + +function Share.Sender(name, create) + local R = Remote() + local s = R[name] + if not s and create then + local count, oldest, oldestT = 0, nil, math.huge + for key, v in pairs(R) do + count = count + 1 + if key ~= SELF_IMPORT and (v.seen or 0) < oldestT then + oldest, oldestT = key, v.seen or 0 + end + end + if count >= MAX_SENDERS and oldest then + R[oldest] = nil + allDirty = true + end + s = { seen = time(), rec = {}, n = 0, cursor = 0 } + R[name] = s + end + return s +end + +local function NoteItemNames(nm) + if type(nm) ~= "table" then + return + end + local names = Codex.db.names + if not nameCount then + nameCount = 0 + for _ in pairs(names) do + nameCount = nameCount + 1 + end + end + for id, name in pairs(nm) do + if names[id] == nil and nameCount < MAX_NAMES then + names[id] = name + nameCount = nameCount + 1 + end + end +end + +-- Bereinigten Datensatz eines Absenders speichern. Rueckgabe: true, wenn neu oder neuer +function Share.StoreRecord(name, rec) + if type(name) ~= "string" or type(rec) ~= "table" or not rec.y then + return false + end + local s = Share.Sender(name, true) + local key = rec.y .. rec.id + local old = s.rec[key] + if old and old.upd >= rec.upd then + return false + end + if rec.y == "g" then + -- Verkehrsverbindungen nur von Berechtigten (oder aus dem eigenen Import) + if name ~= SELF_IMPORT and not IsDevSender(name) then return false end + if not old then + if (s.np or 0) >= MAX_PATH_RECORDS then return false end + s.np = (s.np or 0) + 1 + end + s.rec[key] = rec + s.seen = time() + if Codex.Transport then Codex.Transport.rev = (Codex.Transport.rev or 0) + 1 end + FW:Fire("CODEX_TRANSPORT_CHANGED", rec.id) + return true + end + if rec.y == "h" then + -- Ortspunkte nur von Berechtigten (oder aus dem eigenen Import) + if name ~= SELF_IMPORT and not IsDevSender(name) then return false end + if not old then + if (s.np or 0) >= MAX_PATH_RECORDS then return false end + s.np = (s.np or 0) + 1 + end + s.rec[key] = rec + s.seen = time() + if Codex.Spots then Codex.Spots.Bump(rec.id) end + return true + end + if rec.y == "f" then + -- Flugrouten: in die gemeinsamen Flugdaten einmischen, vom Absender bleibt nur der Stand + local T = Codex.Taxi + if not T or not T.StoreRemote(rec) then return false end + if not old then + if (s.np or 0) >= MAX_PATH_RECORDS then return false end + s.np = (s.np or 0) + 1 + end + s.rec[key] = { y = rec.y, id = rec.id, upd = rec.upd } + s.seen = time() + return true + end + if rec.y == "p" or rec.y == "z" then + -- Wegenetz: sofort in die gemeinsamen Daten einmischen, vom Absender bleibt nur der Stand + local P = Codex.Path + if not P then return false end + if not old then + if (s.np or 0) >= MAX_PATH_RECORDS then return false end + s.np = (s.np or 0) + 1 + end + if rec.y == "p" then + local cells = P.ParseChunk(rec.c) + if not cells then return false end + P.MergeChunk(rec.id, cells) + else + P.MergeLinks(rec.l) + end + s.rec[key] = { y = rec.y, id = rec.id, upd = rec.upd } + s.seen = time() + return true + end + if not old then + if (s.n or 0) >= MAX_RECORDS then + return false + end + s.n = (s.n or 0) + 1 + end + -- Aenderungen von Berechtigten melden (das Modul Freewood-OctoWoW-Dev schreibt sie ins Protokoll) + if Codex.DevLog and (rec.cde or rec.cen or rec.bad ~= nil or rec.ed or (old and (old.cde or old.cen or old.bad ~= nil or old.ed))) and IsDevSender(name) then + local o = old or {} + for _, f in ipairs({ "cde", "cen", "bad" }) do + if rec[f] ~= o[f] then + pcall(Codex.DevLog, { who = name, kind = rec.y, id = rec.id, field = f, old = o[f], new = rec[f], remote = true }) + end + end + local nf, of = EdFields(rec.ed), EdFields(o.ed) + for _, f in ipairs(Share.ED_FIELDS) do + if nf[f] ~= of[f] then + pcall(Codex.DevLog, { who = name, kind = rec.y, id = rec.id, field = f, old = of[f], new = nf[f], remote = true }) + end + end + end + NoteItemNames(rec.nm) + rec.nm = nil + s.rec[key] = rec + s.seen = time() + if rec.y == "x" or rec.y == "t" then + allDirty = true + else + dirtyIds[key] = true + end + return true +end + +-- Startdaten laden: list = Liste roher Datensaetze { y, id, upd, ... }; jeder geht durch Sanitize +-- (Wegenetz fliesst in die gemeinsamen Daten, alles andere liegt als unterste Schicht im Speicher). +-- Rueckgabe: Anzahl angenommener Datensaetze +-- Startdaten in Scheiben laden (Begin, Add je Scheibe, Done), damit das Spiel beim Anmelden nicht stehen bleibt. +-- SetBuiltin(list) macht alles auf einmal. +function Share.BuiltinBegin() + builtin = { rec = {}, n = 0 } +end + +function Share.BuiltinAdd(list, from, to) + local accepted = 0 + local P = Codex.Path + for i = from or 1, to or #list do + local raw = list[i] + local key = type(raw) == "table" and type(raw.y) == "string" and type(raw.id) == "number" + and (raw.y .. math.floor(raw.id)) or nil + local rec = key and Share.Sanitize(key, raw, raw.upd) + if rec then + if rec.y == "p" then + local cells = P and P.ParseChunk(rec.c) + if cells then P.MergeChunk(rec.id, cells) accepted = accepted + 1 end + elseif rec.y == "z" then + if P and P.MergeLinks(rec.l) then accepted = accepted + 1 end + else + builtin.rec[key] = rec + builtin.n = builtin.n + 1 + accepted = accepted + 1 + if rec.y == "u" or rec.y == "o" then NoteItemNames(rec.nm) end + end + end + end + return accepted +end + +function Share.BuiltinDone() + view, allDirty = nil, true + if Codex.Transport then Codex.Transport.rev = (Codex.Transport.rev or 0) + 1 end + if Codex.Spots then Codex.Spots.Bump() end +end + +function Share.SetBuiltin(list) + Share.BuiltinBegin() + local accepted = Share.BuiltinAdd(list or {}, 1, #(list or {})) + Share.BuiltinDone() + return accepted +end + +-- Empfangene Datensaetze einer Art (nur von aktuell berechtigten Absendern, fuer "g") +function Share.RemoteRecords(letter) + local out = {} + for _, rec in pairs(builtin.rec) do + if rec.y == letter and (letter == "g" or letter == "h") then out[#out + 1] = rec end + end + for name, s in pairs(Remote()) do + if (letter ~= "g" and letter ~= "h") or name == SELF_IMPORT or IsDevSender(name) then + for _, rec in pairs(s.rec) do + if rec.y == letter then out[#out + 1] = rec end + end + end + end + return out +end + +function Share.HaveRemote(name, key) + local s = Remote()[name] + local r = s and s.rec[key] + return r and r.upd or nil +end + +function Share.ClearRemote() + local R = Remote() + local count = 0 + for name, s in pairs(R) do + count = count + (s.n or 0) + R[name] = nil + end + allDirty = true + view = nil + if Codex.Path and Codex.Path.ClearRemote then Codex.Path.ClearRemote() end + if Codex.Taxi and Codex.Taxi.ClearRemote then Codex.Taxi.ClearRemote() end + if Codex.Transport then Codex.Transport.rev = (Codex.Transport.rev or 0) + 1 end + if Codex.Spots then Codex.Spots.Bump() end + return count +end + +------------------------------------------------------------------------ +-- Zusammenfuehren +------------------------------------------------------------------------ + +local function AddSample(dst, s) + dst.n = (dst.n or 0) + s.n + dst.sum = (dst.sum or 0) + s.sum + dst.min = dst.min and math.min(dst.min, s.min) or s.min + dst.max = dst.max and math.max(dst.max, s.max) or s.max +end + +local function CopySample(s) + return { n = s.n, sum = s.sum, min = s.min, max = s.max } +end + +local function MergeLevels(dst, src, depth) + for pl, v in pairs(src or {}) do + if depth == 1 then + dst[pl] = dst[pl] or {} + AddSample(dst[pl], v) + else + dst[pl] = dst[pl] or {} + for ml, s in pairs(v) do + dst[pl][ml] = dst[pl][ml] or {} + AddSample(dst[pl][ml], s) + end + end + end +end + +local function MergeBucket(dst, src) + dst.k = (dst.k or 0) + (src.k or 0) + if src.d then + dst.d = dst.d or {} + for id, d in pairs(src.d) do + local t = dst.d[id] + if not t then + t = { n = 0, q = 0 } + dst.d[id] = t + end + t.n = t.n + (d.n or 0) + t.q = t.q + (d.q or 0) + if d.qi then + t.qi = 1 + t.qk = (t.qk or 0) + (d.qk or 0) + end + end + end +end + +local function MergeUnitTTK(dst, src) + for cls, byPl in pairs(src or {}) do + dst[cls] = dst[cls] or {} + for pl, st in pairs(byPl) do + dst[cls][pl] = dst[cls][pl] or {} + AddSample(dst[cls][pl], st) + end + end +end + +local function MergePrices(dst, src) + for price, n in pairs(src or {}) do + dst[price] = (dst[price] or 0) + n + end +end + +local function MergeRoles(dst, src) + for role, n in pairs(src or {}) do + dst[role] = (dst[role] or 0) + n + end +end + +local function MergeWares(dst, src) + for id, w in pairs(src or {}) do + local d = dst[id] + if not d then + d = { pr = {} } + dst[id] = d + end + MergePrices(d.pr, w.pr) + d.st = d.st or w.st + d.lim = d.lim or w.lim + d.ec = d.ec or w.ec + end +end + +local function MergeTrain(dst, src) + for key, r in pairs(src or {}) do + local d = dst[key] + if not d then + d = { pr = {} } + dst[key] = d + end + MergePrices(d.pr, r.pr) + d.nm = d.nm or r.nm + d.sp = d.sp or r.sp + d.rk = d.rk or r.rk + d.lv = d.lv or r.lv + d.sn = d.sn or r.sn + d.sr = d.sr or r.sr + end +end + +local function MergeFlights(dst, src) + for dest, pr in pairs(src or {}) do + dst[dest] = dst[dest] or {} + MergePrices(dst[dest], pr) + end +end + +-- Namen und Markierung kommen nur von berechtigten Absendern; bei mehreren gilt der neueste Stand, +-- eigene Werte gehen vor. Die Absender-Datensaetze bleiben unveraendert (ein gesperrter Absender wird so wieder wirksam). +local function MergeEditorFields(m, e) + if m._init == nil then + m._init = true + local editor = CanEditNames() + m._ownName = editor and (m.cde ~= nil or m.cen ~= nil) + if not editor then m.bad = nil end + m._ownBad = editor and m.bad ~= nil + if not editor then m.ed = nil end + m._ownEd = editor and m.ed ~= nil + end + local r = e.rec + if not IsDevSender(e.s) then return end + if r.ed and not m._ownEd and (not m._edUpd or r.upd > m._edUpd) then + m.ed, m._edUpd = EdFromWire(r.ed), r.upd + end + if (r.cde or r.cen) and not m._ownName and (not m._nameUpd or r.upd > m._nameUpd) then + m.cde, m.cen, m._nameUpd = r.cde, r.cen, r.upd + end + if r.bad ~= nil and not m._ownBad and (not m._badUpd or r.upd > m._badUpd) then + m.bad, m._badUpd = r.bad == 1, r.upd + end +end + +local function MergeEntity(own, list) + local m = {} + local remoteK = 0 + if own then + m.n, m.l1, m.l2, m.c, m.f = own.n, own.l1, own.l2, own.c, own.f + m.ct, m.ne = own.ct, own.ne + m.cde, m.cen = own.cde, own.cen + m.bad = own.bad + m.ed = own.ed + if own.iz then + m.iz = {} + for id, n in pairs(own.iz) do m.iz[id] = n end + end + m.coin, m.coinN = own.coin, own.coinN + m.cells = {} + for key, n in pairs(own.cells or {}) do + m.cells[key] = n + end + MergeBucket(m, own) + if own.alt then + m.alt = {} + MergeBucket(m.alt, own.alt) + end + m.xm, m.xp = {}, {} + MergeLevels(m.xm, own.xm, 2) + MergeLevels(m.xp, own.xp, 1) + m.tt = {} + MergeUnitTTK(m.tt, own.tt) + m.ro, m.wa, m.tr, m.fl = {}, {}, {}, {} + MergeRoles(m.ro, own.ro) + MergeWares(m.wa, own.wa) + MergeTrain(m.tr, own.tr) + MergeFlights(m.fl, own.fl) + m.ti, m.fn, m.trc = own.ti, own.fn, own.trc + else + m.cells, m.xm, m.xp, m.tt = {}, {}, {}, {} + m.ro, m.wa, m.tr, m.fl = {}, {}, {}, {} + end + for _, e in ipairs(list) do + local r = e.rec + m.n = m.n or r.n + MergeEditorFields(m, e) + m.f = m.f or r.f + m.ct = m.ct or r.ct + m.ne = m.ne or r.ne + m.c = m.c or r.c + if r.iz then + m.iz = m.iz or {} + local cnt = 0 + for _ in pairs(m.iz) do cnt = cnt + 1 end + for id, n in pairs(r.iz) do + if m.iz[id] or cnt < 3 then + if not m.iz[id] then cnt = cnt + 1 end + m.iz[id] = (m.iz[id] or 0) + n + end + end + end + if r.l1 then m.l1 = m.l1 and math.min(m.l1, r.l1) or r.l1 end + if r.l2 then m.l2 = m.l2 and math.max(m.l2, r.l2) or r.l2 end + if r.coinN and r.coinN > 0 then + m.coin = (m.coin or 0) + (r.coin or 0) + m.coinN = (m.coinN or 0) + r.coinN + end + for key, n in pairs(r.cells or {}) do + m.cells[key] = (m.cells[key] or 0) + n + end + MergeBucket(m, r) + remoteK = remoteK + (r.k or 0) + if r.alt then + m.alt = m.alt or {} + MergeBucket(m.alt, r.alt) + end + MergeLevels(m.xm, r.xm, 2) + MergeUnitTTK(m.tt, r.tt) + MergeRoles(m.ro, r.ro) + MergeWares(m.wa, r.wa) + MergeTrain(m.tr, r.tr) + MergeFlights(m.fl, r.fl) + m.ti = m.ti or r.ti + m.trc = m.trc or r.trc + m.fn = m.fn or r.fn + end + if next(m.ro) == nil then m.ro = nil end + if next(m.wa) == nil then m.wa = nil end + if next(m.tr) == nil then m.tr = nil end + if Codex.LearnPlanReset then Codex.LearnPlanReset() end + if next(m.fl) == nil then m.fl = nil end + if next(m.tt) == nil then m.tt = nil end + if next(m.cells) == nil then m.cells = nil end + if next(m.xm) == nil then m.xm = nil end + if next(m.xp) == nil then m.xp = nil end + m.rk = remoteK > 0 and remoteK or nil + m.remote = #list + m.k = m.k or nil + return m +end + +local function PlusSample(a, b) + if not b then return a end + if not a then return CopySample(b) end + local out = CopySample(a) + AddSample(out, b) + return out +end + +local function MergeQuest(own, list) + local m = {} + if own then + for k, v in pairs(own) do + m[k] = v + end + end + local objCopied = false + for _, e in ipairs(list) do + local r = e.rec + MergeEditorFields(m, e) + for _, f in ipairs({ "t", "lvl", "pl", "zone", "give", "turn", "si", "xp", "money", "rep", "fixed", "choice", "mn", "rc", "cl", "ev" }) do + if m[f] == nil then + m[f] = r[f] + end + end + if r.tx then + -- Quest-Texte: fehlende Teile aus fremden Daten (neue Tabelle, die eigene bleibt unveraendert) + local tx = {} + for k, v in pairs(m.tx or {}) do tx[k] = v end + for k, v in pairs(r.tx) do + if not tx[k] then tx[k] = v end + end + m.tx = tx + end + if r.pi and r.po then + -- Beobachtungen zusammenzaehlen (neue Tabelle, die eigene bleibt unveraendert) + local pi = {} + for k, v in pairs(m.pi or {}) do pi[k] = v end + local count = 0 + for _ in pairs(pi) do count = count + 1 end + for k, v in pairs(r.pi) do + if pi[k] or count < 8 then + if not pi[k] then count = count + 1 end + pi[k] = (pi[k] or 0) + v + end + end + m.pi, m.po = pi, (m.po or 0) + r.po + end + if r.mi then + local mi = m.mi + if mi then + local lo, hi = math.max(mi.lo, r.mi.lo), math.min(mi.hi, r.mi.hi) + if hi > lo then m.mi = { lo = lo, hi = hi, n = (mi.n or 1) + (r.mi.n or 1) } end + else + m.mi = r.mi + end + end + if r.pre then + -- Vorquests vereinen + local have, merged = {}, {} + for _, p in ipairs(m.pre or {}) do have[p] = true merged[#merged + 1] = p end + for _, p in ipairs(r.pre) do + if not have[p] and #merged < 8 then have[p] = true merged[#merged + 1] = p end + end + m.pre = merged + end + for _, f in ipairs({ "tm", "tr", "ta" }) do + if r[f] then m[f] = PlusSample(m[f], r[f]) end + end + if r.obj then + if not m.obj then + m.obj = r.obj + objCopied = false + else + for i, ro in ipairs(r.obj) do + local mo = m.obj[i] + if mo and (not mo.ty or not ro.ty or mo.ty == ro.ty) and + ((not mo.name and ro.name) or (not mo.item and ro.item) or (not mo.ids and ro.ids)) then + if not objCopied then + local copy = {} + for j, o in ipairs(m.obj) do + local c = {} + for k, v in pairs(o) do c[k] = v end + copy[j] = c + end + m.obj = copy + objCopied = true + end + mo = m.obj[i] + mo.ty = mo.ty or ro.ty + mo.name = mo.name or ro.name + mo.txt = mo.txt or ro.txt + mo.req = mo.req or ro.req + mo.item = mo.item or ro.item + mo.ids = mo.ids or ro.ids + end + end + end + end + if r.qi then + local copy = {} + for item, srcs in pairs(m.qi or {}) do + copy[item] = {} + for k, v in pairs(srcs) do copy[item][k] = v end + end + for item, srcs in pairs(r.qi) do + copy[item] = copy[item] or {} + for k, v in pairs(srcs) do + copy[item][k] = math.max(copy[item][k] or 0, v) + end + end + m.qi = copy + end + end + if not own then + m.seen, m.done, m.first, m.last = 0, 0, nil, nil + m.rem = true + end + return m +end + +local function BuildXP() + local db = Codex.db + local xp = {} + if #xpRecs > 0 then + for pl, byMl in pairs(db.xp or {}) do + for ml, byCls in pairs(byMl) do + for cls, s in pairs(byCls) do + xp[pl] = xp[pl] or {} + xp[pl][ml] = xp[pl][ml] or {} + xp[pl][ml][cls] = CopySample(s) + end + end + end + for _, rec in ipairs(xpRecs) do + for pl, byMl in pairs(rec.xp or {}) do + for ml, byCls in pairs(byMl) do + for cls, s in pairs(byCls) do + xp[pl] = xp[pl] or {} + xp[pl][ml] = xp[pl][ml] or {} + xp[pl][ml][cls] = xp[pl][ml][cls] or {} + AddSample(xp[pl][ml][cls], s) + end + end + end + end + view.xp = xp + else + view.xp = db.xp + end + xpDirty = false +end + +local function BuildTTK() + local db = Codex.db + if #ttkRecs == 0 then + view.ttk = db.ttk + return + end + local tk = {} + local function Add(src) + for cls, byPl in pairs(src or {}) do + for pl, byDelta in pairs(byPl) do + for d, sm in pairs(byDelta) do + tk[cls] = tk[cls] or {} + tk[cls][pl] = tk[cls][pl] or {} + tk[cls][pl][d] = PlusSample(tk[cls][pl][d], sm) + end + end + end + end + Add(db.ttk) + for _, rec in ipairs(ttkRecs) do + Add(rec.tk) + end + view.ttk = tk +end + +local function Rebuild() + local db = Codex.db + index = { u = {}, o = {}, q = {}, i = {} } + xpRecs = {} + ttkRecs = {} + local names = {} + for name in pairs(Remote()) do + names[#names + 1] = name + end + table.sort(names) + for _, name in ipairs(names) do + local s = Remote()[name] + for _, rec in pairs(s.rec) do + if rec.y == "x" then + xpRecs[#xpRecs + 1] = rec + elseif rec.y == "t" then + ttkRecs[#ttkRecs + 1] = rec + elseif rec.y == "p" or rec.y == "z" or rec.y == "g" or rec.y == "h" or rec.y == "f" then + -- Wegenetz liegt schon in den gemeinsamen Daten (Codex.Path), Verkehrsverbindungen liest Codex.Transport + else + local bucket = index[rec.y] + bucket[rec.id] = bucket[rec.id] or {} + local list = bucket[rec.id] + list[#list + 1] = { s = name, rec = rec } + end + end + end + -- Startdaten ganz unten (nach allen Absendern) + local bkeys = {} + for key in pairs(builtin.rec) do bkeys[#bkeys + 1] = key end + table.sort(bkeys) + for _, key in ipairs(bkeys) do + local rec = builtin.rec[key] + if rec.y == "x" then + xpRecs[#xpRecs + 1] = rec + elseif rec.y == "t" then + ttkRecs[#ttkRecs + 1] = rec + elseif index[rec.y] then + local bucket = index[rec.y] + bucket[rec.id] = bucket[rec.id] or {} + table.insert(bucket[rec.id], { s = BUILTIN, rec = rec }) + end + end + view = { quests = {}, units = {}, objects = {}, ied = {}, xpb = db.xpb, names = db.names } + for letter, field in pairs(KIND_OF) do + local out = view[field] + for id, e in pairs(db[field]) do + out[id] = e.ed and Codex.ApplyEd(e, field) or e + end + for id, list in pairs(index[letter]) do + local merged + if letter == "q" then + merged = MergeQuest(db[field][id], list) + else + merged = MergeEntity(db[field][id], list) + end + if merged.bad == true and not CanEditNames() then merged = nil end + if merged then merged = Codex.ApplyEd(merged, field, true) end + out[id] = merged + end + end + BuildXP() + BuildTTK() + allDirty = false + dirtyIds = {} +end + +local function Refresh(key) + local letter, num = key:match("^([uoqi])(%d+)$") + if not letter then + return + end + local id = tonumber(num) + local field = KIND_OF[letter] + local list = index[letter][id] + -- neue Fremd-Eintraege in den Index aufnehmen + list = nil + for name, s in pairs(Remote()) do + local rec = s.rec[key] + if rec then + list = list or {} + list[#list + 1] = { s = name, rec = rec } + end + end + if list then + table.sort(list, function(a, b) return a.s < b.s end) + end + if builtin.rec[key] then + list = list or {} + list[#list + 1] = { s = BUILTIN, rec = builtin.rec[key] } + end + index[letter][id] = list + local own = Codex.db[field][id] + if list then + local merged = letter == "q" and MergeQuest(own, list) or MergeEntity(own, list) + if merged.bad == true and not CanEditNames() then merged = nil end + if merged then merged = Codex.ApplyEd(merged, field, true) end + view[field][id] = merged + else + view[field][id] = own and own.ed and Codex.ApplyEd(own, field) or own + end +end + +-- Sicht fuer die Anzeige: Felder quests, units, objects, xp, xpb, names +-- Berechtigungen haben sich geaendert: Sicht neu aufbauen +function Share.TrustChanged() + allDirty = true +end + +-- Eigene Eigenschaften (ed) muessen auch ohne fremde Daten in die Sicht +local hasEd +function Share.HasEd() + if hasEd == nil then + hasEd = false + -- aeltere Form der Item-Eigenschaften (ohne Huelle ed) umwandeln + for id, e in pairs(Codex.db.ied or {}) do + if e.ed == nil and (e.z or e.dg ~= nil) then Codex.db.ied[id] = { ed = e } end + end + for _, field in ipairs({ "units", "objects", "quests", "ied" }) do + for _, e in pairs(Codex.db[field] or {}) do + if e.ed then hasEd = true break end + end + if hasEd then break end + end + end + return hasEd +end + +function Share.NoteEd(present) + if present then hasEd = true else hasEd = nil end -- nil: beim naechsten Mal neu pruefen + view, allDirty = nil, true +end + +function Share.View() + if not Share.HasRemote() and not Share.HasEd() then + view, allDirty = nil, true + return Codex.db + end + if not view or allDirty then + Rebuild() + elseif xpDirty then + -- Erfahrungs- und Kampfzeit-Sicht nicht nach jedem Kill neu bauen (kann mit fremden Daten mehrere 100 ms dauern) + local t = _G.GetTime and _G.GetTime() or 0 + if t - lastXPBuild >= 20 then + lastXPBuild = t + BuildXP() + BuildTTK() + end + end + if next(dirtyIds) then + local ids = dirtyIds + dirtyIds = {} + for key in pairs(ids) do + Refresh(key) + end + end + return view +end + +-- Eigene Aenderung: nur noetig, wenn es zu diesem Datensatz fremde Daten gibt oder er in der Sicht fehlt +function Share.OnChanged(kind, id) + if not view then + return + end + if kind == "xp" or kind == "ttk" then + if #xpRecs > 0 or #ttkRecs > 0 then + xpDirty = true + end + return + end + local letter = LETTER_OF[kind] + if letter and type(id) == "number" then + local field = KIND_OF[letter] + if view[field][id] == nil or index[letter][id] then + dirtyIds[letter .. id] = true + end + end +end + +FW:On("CODEX_CHANGED", function(_, kind, id) + Share.OnChanged(kind, id) +end, Share) + +-- Gemessene Kampfdauer in Sekunden und Anzahl fuer (Klasse, Spielerstufe, Stufenabstand) +function Share.TTKMeasured(cls, pl, delta) + local tk = Share.View().ttk + local s = tk and tk[cls] and tk[cls][pl] and tk[cls][pl][delta] + if not s or not s.n or s.n < 1 then + return nil + end + return s.sum / s.n / 10, s.n +end + +-- Kampfdauer aller Klassen bei (Spielerstufe, Stufenabstand): Liste { cls, secs, n } nach Dauer sortiert +function Share.TTKClasses(pl, delta) + local list = {} + local tk = Share.View().ttk + for cls, byPl in pairs(tk or {}) do + local s = byPl[pl] and byPl[pl][delta] + if s and s.n and s.n > 0 then + list[#list + 1] = { cls = cls, secs = s.sum / s.n / 10, n = s.n } + end + end + table.sort(list, function(a, b) + if a.secs ~= b.secs then return a.secs < b.secs end + return a.cls < b.cls + end) + return list +end + +-- Gemessene Erfahrung fuer (Spielerstufe, Gegnerstufe, Klasse): Mittelwert und Anzahl, +-- nur wenn die Messwerte zusammenpassen +function Share.XPMeasured(pl, ml, cls) + local xp = Share.View().xp + local s = xp and xp[pl] and xp[pl][ml] and xp[pl][ml][cls] + if not s or not s.n or s.n < 1 then + return nil + end + local avg = s.sum / s.n + if (s.max - s.min) > math.max(2, avg * 0.1) then + return nil + end + return math.floor(avg + 0.5), s.n +end + +------------------------------------------------------------------------ +-- Export und Import als Code +------------------------------------------------------------------------ + +-- Empfangene Datensaetze aller Absender (je Schluessel der neueste), fuer berechtigte Bearbeiter +local function ReceivedRecords(limit, skip) + local best = {} + for _, s in pairs(Remote()) do + for key, rec in pairs(s.rec) do + if rec.y ~= "p" and rec.y ~= "z" and rec.y ~= "f" and not skip[key] and (not best[key] or rec.upd > best[key].upd) then + best[key] = rec + end + end + end + local list = {} + for _, rec in pairs(best) do list[#list + 1] = rec end + table.sort(list, function(a, b) + if a.upd ~= b.upd then return a.upd > b.upd end + return (a.y .. a.id) < (b.y .. b.id) + end) + local out = {} + for i = 1, math.min(#list, limit) do out[i] = list[i] end + return out +end + +local function ExportList(limit, withReceived) + local list = Share.Scan(true) + table.sort(list, function(a, b) + if a.upd ~= b.upd then + return a.upd > b.upd + end + return a.key < b.key + end) + local out, have = {}, {} + for i = 1, math.min(#list, limit) do + local rec = Share.OwnRecord(list[i].key) + if rec then + out[#out + 1] = rec + have[list[i].key] = true + end + end + if withReceived and #out < limit then + for _, rec in ipairs(ReceivedRecords(limit - #out, have)) do + out[#out + 1] = rec + end + end + return out +end + +-- withReceived: auch empfangene Daten anderer Spieler (nur fuer Berechtigte mit Modul Freewood-OctoWoW-Dev) +function Share.ExportData(withReceived) + if withReceived and not (Codex.Trust and Codex.Trust.Can("export")) then + return {} + end + local limit = 1500 + local data + repeat + data = { list = ExportList(limit, withReceived) } + local ok = true + if FW.Export and FW.Export.Encode then + local text = FW.Export.Encode("codex", data) + ok = text ~= nil + end + if ok or limit <= 25 or #data.list == 0 then + break + end + limit = math.floor(limit / 2) + until false + if #data.list == 0 then + return {} + end + return data +end + +function Share.ValidateImport(data) + return type(data) == "table" and type(data.list) == "table" and #data.list > 0 and #data.list <= 5000 +end + +function Share.ImportData(data) + local L = FW.L + local added, skipped = 0, 0 + for _, raw in ipairs(data.list or {}) do + local key = type(raw) == "table" and type(raw.y) == "string" and type(raw.id) == "number" + and (raw.y .. math.floor(raw.id)) or nil + local rec = key and Share.Sanitize(key, raw, raw.upd) + if rec and Share.StoreRecord(SELF_IMPORT, rec) then + added = added + 1 + else + skipped = skipped + 1 + end + end + FW:Fire("CODEX_CHANGED", "import") + return string.format(L.CODEX_IMPORT_STATS, added, skipped) +end + +if FW.Export and FW.Export.RegisterHandler then + FW.Export.RegisterHandler("codex", { + label = function() + return FW.L.CODEX_EXPORT_LABEL + end, + export = function() + return Share.ExportData() + end, + validate = function(data) + return Share.ValidateImport(data) + end, + import = function(data) + return Share.ImportData(data) + end, + }) +end diff --git a/CodexShareUI.lua b/CodexShareUI.lua new file mode 100644 index 0000000..972e421 --- /dev/null +++ b/CodexShareUI.lua @@ -0,0 +1,94 @@ +-- Freewood-OctoWoW-Codex: Abschnitt "Datenbank teilen" in den Einstellungen + +local ADDON, NS = ... + +local FW = _G.Freewood +local Codex = NS and NS.Codex +local Sync = NS and NS.Sync +local Share = NS and NS.Share +if not FW or not Codex or not Sync or not Share or not FW.UI or not FW.UI.RegisterSettingsSection then + return +end + +local UI = FW.UI + +local function Build(parent) + local L = FW.L + local f, stack = UI.CreateSection(parent, L.CODEX_SHARE_HEADER) + + local note = UI.CreateLabel(f, L.CODEX_SHARE_NOTE, { size = 11, color = "textDim", justifyV = "TOP" }) + stack:Add(note, { fit = true, gap = 8 }) + + local box = UI.CreateCheckbox(f, L.CODEX_SHARE_SWITCH, function() + return Sync:IsEnabled() + end, function(on) + Sync:SetEnabled(on) + end) + stack:Add(box, { fit = true, gap = 10 }) + UI.AttachTooltip(box, function() return L.CODEX_SHARE_SWITCH end, function() return L.CODEX_SHARE_SWITCH_TIP end) + + local callButton = UI.CreateButton(f, L.CODEX_SHARE_NOW, 200, 26, function() + if Sync:IsEnabled() then + Sync:JoinGlobal() + Share.Scan(true) + Sync:SendHello() + end + end, "default") + stack:Add(callButton, { height = 26, gap = 12 }) + + local clearButton + local armed = false + clearButton = UI.CreateButton(f, L.CODEX_SHARE_CLEAR, 240, 26, function() + if not armed then + armed = true + clearButton:SetLabel(L.CODEX_SHARE_CLEAR_SURE) + C_Timer.After(5, function() + armed = false + clearButton:SetLabel(L.CODEX_SHARE_CLEAR) + end) + return + end + armed = false + clearButton:SetLabel(L.CODEX_SHARE_CLEAR) + local n = Share.ClearRemote() + FW:Print(string.format(L.CODEX_SHARE_CLEARED, n)) + FW:Fire("CODEX_CHANGED", "import") + FW:Fire("CODEX_SYNC_CHANGED") + end, "default") + stack:Add(clearButton, { height = 26, gap = 8 }) + + local status = UI.CreateLabel(f, "", { size = 11, color = "textDim", justifyV = "TOP" }) + stack:Add(status, { fit = true, gap = 10 }) + + function f.Refresh() + box:Refresh() + local st = Sync:Stats() + local text = string.format(L.CODEX_SHARE_STATS, st.sent, st.received) + text = text .. "\n" .. string.format(L.CODEX_SHARE_SOURCES, st.senders, st.records) + text = text .. "\n" .. string.format(L.CODEX_SHARE_HEARD, st.heard or 0) + if Sync:IsEnabled() then + local key = "CODEX_SHARE_GLOBAL_" .. string.upper(st.global or "off") + text = text .. "\n" .. (L[key] or L.CODEX_SHARE_GLOBAL_OFF) + end + status:SetText(text) + stack:Layout() + end + + FW:On("CODEX_SYNC_CHANGED", function() + if f:IsVisible() then + f.Refresh() + end + end, f) + + stack:Layout() + return f +end + +UI:RegisterSettingsSection("codex-share", { + order = 105, + height = 360, + build = Build, + refresh = function(frame) + frame.Refresh() + end, +})