Files
2026-10-27 14:13:20 +00:00

628 lines
21 KiB
Lua

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