-- Freewood-OctoWoW-Vendor: Listen fuer den Haendler. -- -- Zwei Listen (gelten fuer den ganzen Account): -- black = "Nie verkaufen" steht immer an erster Stelle: Was hier steht, wird nie verkauft -- (auch nicht grau, nicht ueber den Knopf, nicht bei der Automatik). -- white = "Immer verkaufen" wird beim Haendler mitverkauft. -- Dazu: graue Items (Qualitaet 0) werden mitverkauft, wenn der Haken "gray" an ist. -- -- Reihenfolge der Entscheidung je Item (die erste zutreffende Regel gilt): -- 1. Nie-verkaufen-Liste -> bleibt -- 2. geschuetzt (Quest-Item, von einer angenommenen Quest gebraucht) -> bleibt -- 3. Immer-verkaufen-Liste -> wird verkauft -- 4. grau und Haken "gray" -> wird verkauft -- 5. sonst -> bleibt -- Items ohne Verkaufspreis werden nie angefasst. -- -- Verkauft wird mit UseContainerItem, solange das Haendlerfenster offen ist, ein Item je kurzem Takt. -- Der Haendler haelt die letzten 12 verkauften Items im Rueckkauf. -- -- Gespeichert: Account-weit Vendor.db = { black, white, settings }, je Charakter Vendor.chardb.log (zuletzt verkauft). local ADDON, NS = ... local FW = _G.Freewood if not FW or not FW:RequireAPI(1, ADDON) then return end local Util = FW.Util local L = FW.L local MAX_LIST = 500 local MAX_LOG = 30 local MAX_IMPORT = 1000 local SELL_GAP = 0.15 local function GetMeta(field) local fn = (C_AddOns and C_AddOns.GetAddOnMetadata) or GetAddOnMetadata if fn then local ok, value = pcall(fn, ADDON, field) if ok then return value end end return nil end local Vendor = FW:NewModule("Vendor", { version = GetMeta("Version") or "dev", title = L.VENDOR_TITLE, description = L.VENDOR_DESC, defaults = { settings = { auto = false, gray = true, chat = true }, black = {}, white = {}, }, charDefaults = { log = {}, }, }) NS.Vendor = Vendor FW.Vendor = Vendor Vendor.merchant = false Vendor.run = nil Vendor.sellable = 0 local function Money(c) c = math.floor(tonumber(c) or 0) local g, s, k = math.floor(c / 10000), math.floor(c / 100) % 100, c % 100 local out = {} if g > 0 then out[#out + 1] = g .. "g" end if s > 0 then out[#out + 1] = s .. "s" end if k > 0 or #out == 0 then out[#out + 1] = k .. "c" end return table.concat(out, " ") end Vendor.Money = Money ------------------------------------------------------------------------ -- Taschen ------------------------------------------------------------------------ local function NumSlots(bag) local fn = (C_Container and C_Container.GetContainerNumSlots) or GetContainerNumSlots if type(fn) ~= "function" then return 0 end local ok, n = pcall(fn, bag) return (ok and tonumber(n)) or 0 end -- Rueckgabe: itemID, Anzahl, gesperrt, Qualitaet (nil, wenn unbekannt) local function SlotInfo(bag, slot) if C_Container and C_Container.GetContainerItemInfo then local ok, info = pcall(C_Container.GetContainerItemInfo, bag, slot) if ok and type(info) == "table" then return tonumber(info.itemID), tonumber(info.stackCount) or 1, info.isLocked and true or false, tonumber(info.quality) end end if GetContainerItemInfo then local ok, _, count, locked, quality, _, _, link, _, _, itemID = pcall(GetContainerItemInfo, bag, slot) if ok then local id = tonumber(itemID) or (type(link) == "string" and tonumber(link:match("item:(%d+)"))) or nil if not id and GetContainerItemLink then local ok2, l2 = pcall(GetContainerItemLink, bag, slot) if ok2 and type(l2) == "string" then id = tonumber(l2:match("item:(%d+)")) end end return id, tonumber(count) or 1, locked and true or false, tonumber(quality) end end return nil end Vendor.SlotInfo = SlotInfo local function BagCount() return tonumber(_G.NUM_BAG_SLOTS) or 4 end local function UseSlot(bag, slot) local fn = (C_Container and C_Container.UseContainerItem) or UseContainerItem if type(fn) == "function" then fn(bag, slot) return true end return false end local function CursorHasAnything() return GetCursorInfo and GetCursorInfo() ~= nil end local function ItemName(itemID) local info = Util.GetItem and Util.GetItem(itemID) if info and info.name then return info.name end if C_Item and C_Item.GetItemNameByID then local ok, name = pcall(C_Item.GetItemNameByID, itemID) if ok and type(name) == "string" then return name end end return nil end -- Item-ID aus Zahl, Text oder Item-Link; nil bei Unsinn function Vendor.ParseItem(input) local id if type(input) == "number" then id = input elseif type(input) == "string" then id = tonumber(input:match("item:(%d+)")) or tonumber(input:match("^%s*(%d+)%s*$")) end if not id or id ~= math.floor(id) or id < 1 or id > 10000000 then return nil end return id end ------------------------------------------------------------------------ -- Listen ------------------------------------------------------------------------ local function ListOf(self, which) return which == "black" and self.db.black or self.db.white end function Vendor:IsBlack(itemID) return itemID ~= nil and self.db.black[itemID] ~= nil end function Vendor:IsWhite(itemID) return itemID ~= nil and self.db.white[itemID] ~= nil end function Vendor:Count(which) local n = 0 for _ in pairs(ListOf(self, which)) do n = n + 1 end return n end -- which = "black" | "white". Ein Item steht nie auf beiden Listen: Nie-verkaufen verdraengt Immer-verkaufen. -- Rueckgabe: true, id oder false, Grund ("invalid" | "already" | "full" | "black") function Vendor:Add(which, input) local id = self.ParseItem(input) if not id then return false, "invalid" end local list = ListOf(self, which) if list[id] then return false, "already" end if which == "white" and self.db.black[id] then return false, "black" -- steht auf "Nie verkaufen": zuerst dort entfernen end if self:Count(which) >= MAX_LIST then return false, "full" end if which == "black" and self.db.white[id] then self.db.white[id] = nil -- Nie-verkaufen gewinnt end local name = ItemName(id) list[id] = { name = name and name:sub(1, 80) or nil, added = time() } FW:Fire("VENDOR_LIST_CHANGED") self:Recount() return true, id end function Vendor:Remove(which, itemID) local list = ListOf(self, which) if list[itemID] then list[itemID] = nil FW:Fire("VENDOR_LIST_CHANGED") self:Recount() return true end return false end -- Sortierte Liste { { id, name } } function Vendor:List(which) local out = {} for id, entry in pairs(ListOf(self, which)) do local name = ItemName(id) or entry.name if name and entry.name ~= name then entry.name = name:sub(1, 80) end out[#out + 1] = { id = id, name = name } end table.sort(out, function(a, b) local an, bn = a.name and a.name:lower() or "\255", b.name and b.name:lower() or "\255" if an ~= bn then return an < bn end return a.id < b.id end) return out end ------------------------------------------------------------------------ -- Schutz: Items, die eine angenommene Quest noch braucht ------------------------------------------------------------------------ local protectedIDs, protectedNames = {}, {} -- Liest die Questliste und den Codex neu. Wird beim Oeffnen des Haendlers und vor jedem Verkauf aufgerufen. function Vendor:RefreshProtected() local ids, names = {}, {} -- 1. Questliste: Ziele vom Typ "item", die noch nicht erfuellt sind (Name aus "Wolfsfleisch: 3/8") if GetNumQuestLogEntries and GetQuestLogTitle and GetNumQuestLeaderBoards and GetQuestLogLeaderBoard then local n = tonumber((GetNumQuestLogEntries())) or 0 for i = 1, n do local i = i local ok, _, _, _, isHeader = pcall(GetQuestLogTitle, i) if ok and not isHeader then local nb = tonumber((GetNumQuestLeaderBoards(i))) or 0 for j = 1, nb do local j = j local ok2, desc, kind, done = pcall(GetQuestLogLeaderBoard, j, i) if ok2 and kind == "item" and not done and type(desc) == "string" then local name = desc:match("^(.-):%s*%-?%d+%s*/%s*%d+") or desc names[name:lower()] = true end end end end end -- 2. Codex: Quest-Items aller angenommenen Quests (deckt auch zugeklappte Gebiete der Questliste ab) local C = FW.Codex if C and C.LocalActive and C.V then local ok, active = pcall(C.LocalActive) local V = ok and C.V() or nil if ok and type(active) == "table" and V and V.quests then for _, qid in ipairs(active) do local q = V.quests[qid] if type(q) == "table" and type(q.qi) == "table" then for itemID in pairs(q.qi) do if type(itemID) == "number" then ids[itemID] = true end end end end end end protectedIDs, protectedNames = ids, names end function Vendor:IsProtected(itemID, info) if protectedIDs[itemID] then return true end if info then if info.classID == 12 or info.type == "Quest" then return true -- Quest-Item end if info.name and protectedNames[info.name:lower()] then return true end end return false end ------------------------------------------------------------------------ -- Entscheidung ------------------------------------------------------------------------ -- Rueckgabe: verkaufen (bool), Grund ("black" | "protected" | "white" | "gray" | "noprice" | "keep") function Vendor:Decide(itemID, quality, info) if self.db.black[itemID] then return false, "black" end if not info then return false, "keep" end if self:IsProtected(itemID, info) then return false, "protected" end local sell, why = false, "keep" if self.db.white[itemID] then sell, why = true, "white" elseif self.db.settings.gray and quality == 0 then sell, why = true, "gray" end if sell and (info.sellPrice or 0) <= 0 then return false, "noprice" end return sell, why end -- Alle Items in den Taschen, die jetzt verkauft wuerden: { { bag, slot, id, count, price, why } }, Gesamtpreis function Vendor:Collect() self:RefreshProtected() local list, total = {}, 0 for bag = 0, BagCount() do for slot = 1, NumSlots(bag) do local id, count, _, q = SlotInfo(bag, slot) if id then local info = Util.GetItem and Util.GetItem(id) local quality = q if quality == nil and info then quality = info.quality end if info and quality ~= nil then local sell, why = self:Decide(id, quality, info) if sell then local price = (info.sellPrice or 0) * (count or 1) list[#list + 1] = { bag = bag, slot = slot, id = id, count = count or 1, price = price, why = why } total = total + price end end end end end return list, total end -- Taschen-Inhalt ohne Doppelte: { { id, count } } (fuer die Anzeige "In deinen Taschen") function Vendor:BagItems() local seen, out = {}, {} for bag = 0, BagCount() do for slot = 1, NumSlots(bag) do local id, count = SlotInfo(bag, slot) if id then if seen[id] then seen[id].count = seen[id].count + (count or 1) else local e = { id = id, count = count or 1 } seen[id] = e out[#out + 1] = e end end end end return out end function Vendor:Recount() local n = 0 if self.merchant then n = #self:Collect() end if n ~= self.sellable then self.sellable = n FW:Fire("VENDOR_COUNT_CHANGED", n) end return n end ------------------------------------------------------------------------ -- Verkaufen ------------------------------------------------------------------------ local function AddLog(self, id, count, price) local log = self.chardb.log local name = ItemName(id) or ("#" .. id) table.insert(log, 1, { id = id, name = name:sub(1, 80), count = count or 1, price = price or 0, t = time() }) while #log > MAX_LOG do table.remove(log) end end local function MerchantOpen() return _G.MerchantFrame and _G.MerchantFrame.IsShown and _G.MerchantFrame:IsShown() and true or false end local function Finish(self, reason) local run = self.run self.run = nil if self.driver then self.driver:Hide() end if not run then return end if run.n > 0 then FW:Fire("VENDOR_LOG_CHANGED") if self.db.settings.chat then FW:Print(string.format(FW.L.VENDOR_DONE, run.n, Money(run.money))) end end if reason == "closed" and run.i <= #run.list and self.db.settings.chat then FW:Print(string.format(FW.L.VENDOR_STOPPED, run.i - 1, #run.list)) end self:Recount() end -- Startet den Verkauf. manual = true: Knopfdruck (meldet auch, wenn nichts zu verkaufen ist) function Vendor:Start(manual) if self.run then return false end if not self.merchant or not MerchantOpen() then if manual then FW:Print(FW.L.VENDOR_NO_MERCHANT) end return false end local list = self:Collect() if #list == 0 then if manual then FW:Print(FW.L.VENDOR_NOTHING) end return false end self.run = { list = list, i = 1, n = 0, money = 0, wait = 0 } self.driver:Show() return true end -- Ein Schritt: das naechste Item verkaufen, nach erneuter Pruefung function Vendor:Step() local run = self.run if not run then return end if not self.merchant or not MerchantOpen() then return Finish(self, "closed") end if CursorHasAnything() then return -- warten, bis der Cursor frei ist (UseContainerItem koennte sonst etwas anderes tun) end local e = run.list[run.i] if not e then return Finish(self, "done") end run.i = run.i + 1 -- Neu pruefen: gleiche Stelle, gleiches Item, nicht gesperrt, und die Regeln erlauben es immer noch self:RefreshProtected() local id, count, locked, q = SlotInfo(e.bag, e.slot) if id ~= e.id or locked then return end local info = Util.GetItem and Util.GetItem(id) local quality = q if quality == nil and info then quality = info.quality end if not info or quality == nil then return end local sell = self:Decide(id, quality, info) if not sell then return end if UseSlot(e.bag, e.slot) then local price = (info.sellPrice or 0) * (count or 1) run.n = run.n + 1 run.money = run.money + price AddLog(self, id, count, price) else Finish(self, "noapi") end end function Vendor:OnMerchantShow() self.merchant = true FW:Fire("VENDOR_MERCHANT_CHANGED", true) self:Recount() if self.db.settings.auto and C_Timer and C_Timer.After then C_Timer.After(0.4, function() if Vendor.merchant and Vendor.db.settings.auto then Vendor:Start(false) end end) end end function Vendor:OnMerchantClosed() self.merchant = false if self.run then Finish(self, "closed") end self.sellable = 0 FW:Fire("VENDOR_MERCHANT_CHANGED", false) end local bagAcc = 0 function Vendor:OnBagUpdate() if self.merchant and not self.run then local now = GetTime and GetTime() or 0 if now - bagAcc > 0.3 then bagAcc = now self:Recount() end end end function Vendor:OnEnable() self.driver = self.driver or CreateFrame("Frame") local acc = 0 self.driver:SetScript("OnUpdate", function(_, elapsed) acc = acc + (elapsed or 0) if acc < SELL_GAP then return end acc = 0 local ok, err = pcall(Vendor.Step, Vendor) if not ok then Vendor.run = nil Vendor.driver:Hide() end end) self.driver:Hide() FW:RegisterEvent("MERCHANT_SHOW", self.OnMerchantShow, self) FW:RegisterEvent("MERCHANT_CLOSED", self.OnMerchantClosed, self) FW:RegisterEvent("BAG_UPDATE", self.OnBagUpdate, self) FW:RegisterEvent("BAG_UPDATE_DELAYED", self.OnBagUpdate, self) end function Vendor:OnDisable() for _, event in ipairs({ "MERCHANT_SHOW", "MERCHANT_CLOSED", "BAG_UPDATE", "BAG_UPDATE_DELAYED" }) do FW:UnregisterEvent(event, self) end if self.driver then self.driver:Hide() end self.run = nil end ------------------------------------------------------------------------ -- Export / Import ------------------------------------------------------------------------ function Vendor:ExportData() local data = {} for _, which in ipairs({ "black", "white" }) do local list = {} for _, e in ipairs(self:List(which)) do list[#list + 1] = { id = e.id, name = e.name } end if #list > 0 then data[which] = list end end return data end function Vendor:ValidateImport(data) if type(data) ~= "table" or (type(data.black) ~= "table" and type(data.white) ~= "table") then return false, L.VENDOR_IMPORT_EMPTY end if #(data.black or {}) + #(data.white or {}) > MAX_IMPORT then return false, L.VENDOR_IMPORT_TOO_MANY end return true end function Vendor:ImportData(data) local added, known = 0, 0 -- zuerst "Nie verkaufen", damit es "Immer verkaufen" verdraengt for _, which in ipairs({ "black", "white" }) do for _, e in ipairs(data[which] or {}) do local id = type(e) == "table" and self.ParseItem(e.id) or nil if id then local list = ListOf(self, which) if list[id] or (which == "white" and self.db.black[id]) then known = known + 1 elseif self:Count(which) < MAX_LIST then if which == "black" then self.db.white[id] = nil end local name = type(e.name) == "string" and e.name:sub(1, 80) or ItemName(id) list[id] = { name = name, added = time() } added = added + 1 end end end end FW:Fire("VENDOR_LIST_CHANGED") self:Recount() return string.format(FW.L.VENDOR_IMPORT_STATS, added, known) end if FW.Export and FW.Export.RegisterHandler then FW.Export.RegisterHandler("vendorlist", { label = function() return FW.L.VENDOR_EXPORT_LABEL end, export = function() return Vendor:ExportData() end, validate = function(data) return Vendor:ValidateImport(data) end, import = function(data) return Vendor:ImportData(data) end, }) end ------------------------------------------------------------------------ -- Test im Spiel: /fwsell test (verkauft nichts) ------------------------------------------------------------------------ function Vendor:RunTest() local Lc = FW.L local function yn(v) return v and Lc.VENDOR_YES or Lc.VENDOR_NO end FW:Print(string.format(Lc.VENDOR_TEST_API, yn(C_Container and C_Container.UseContainerItem or UseContainerItem), yn(C_Container and C_Container.GetContainerItemInfo or GetContainerItemInfo), yn(MerchantOpen()))) self:RefreshProtected() local gray, white, black, prot, noq, noprice, keep = 0, 0, 0, 0, 0, 0, 0 local total = 0 for bag = 0, BagCount() do for slot = 1, NumSlots(bag) do local id, count, _, q = SlotInfo(bag, slot) if id then local info = Util.GetItem and Util.GetItem(id) local quality = q if quality == nil and info then quality = info.quality end if not info or quality == nil then noq = noq + 1 else local sell, why = self:Decide(id, quality, info) if sell then if why == "gray" then gray = gray + 1 else white = white + 1 end total = total + (info.sellPrice or 0) * (count or 1) elseif why == "black" then black = black + 1 elseif why == "protected" then prot = prot + 1 elseif why == "noprice" then noprice = noprice + 1 else keep = keep + 1 end end end end end FW:Print(string.format(Lc.VENDOR_TEST_RESULT, gray, white, Money(total))) FW:Print(string.format(Lc.VENDOR_TEST_KEPT, black, prot, noprice, keep, noq)) end