mirror of
https://github.com/Lacustat/Freewood-OctoWoW-Loot.git
synced 2026-10-30 20:36:57 +00:00
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016onJCSHLwhs9udhC9ZLzk9
543 lines
16 KiB
Lua
543 lines
16 KiB
Lua
-- Freewood-OctoWoW-Loot: Blacklist fuer Beute.
|
|
--
|
|
-- Ablauf:
|
|
-- 1. Steht ein Blacklist-Item im Beutefenster, wird es automatisch mitgenommen
|
|
-- (damit die Leiche leer ist und sich haeuten laesst).
|
|
-- 2. Liegt ein Blacklist-Item in den Taschen, kommt es in die Warteschlange (Loot.pending).
|
|
-- 3. Der naechste Mausklick in der Spielwelt loescht ein wartendes Item.
|
|
-- Grund: das Spiel erlaubt Addons das Loeschen nur waehrend einer echten Eingabe
|
|
-- (Hardware-Ereignis) und nur ein Item pro Eingabe.
|
|
--
|
|
-- Gespeichert pro Charakter in Loot.chardb (Einstellungen Account-weit in Loot.db):
|
|
-- blacklist = { [itemID] = { name, added } }
|
|
-- recent = { { id, t }, ... } zuletzt erhaltene Items, neueste zuerst
|
|
-- log = { { id, name, count, t }, ... } zuletzt geloeschte Items, neueste zuerst
|
|
-- settings = { enabled, autoLoot, chat }
|
|
|
|
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_RECENT = 20
|
|
local MAX_LOG = 30
|
|
local MAX_IMPORT = 500
|
|
local MAX_FAILURES = 3
|
|
|
|
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 Loot = FW:NewModule("Loot", {
|
|
version = GetMeta("Version") or "dev",
|
|
title = L.LOOT_TITLE,
|
|
description = L.LOOT_DESC,
|
|
-- Einstellungen gelten fuer den Account, Blacklist/Zuletzt/Protokoll nur fuer den Charakter
|
|
defaults = {
|
|
settings = { enabled = true, autoLoot = true, chat = true },
|
|
},
|
|
charDefaults = {
|
|
blacklist = {},
|
|
recent = {},
|
|
log = {},
|
|
},
|
|
})
|
|
NS.Loot = Loot
|
|
FW.Loot = Loot
|
|
|
|
Loot.pending = {} -- { { bag, slot, id, count } }
|
|
Loot.failures = 0
|
|
Loot.blocked = false
|
|
|
|
------------------------------------------------------------------------
|
|
-- Zugriff auf Taschen (neue und alte API)
|
|
------------------------------------------------------------------------
|
|
|
|
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
|
|
local function SlotItem(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
|
|
end
|
|
return nil
|
|
end
|
|
if GetContainerItemInfo then
|
|
local ok, _, count, locked, _, _, _, link, _, _, itemID = pcall(GetContainerItemInfo, bag, slot)
|
|
if ok then
|
|
return tonumber(itemID) or (type(link) == "string" and tonumber(link:match("item:(%d+)"))) or nil,
|
|
tonumber(count) or 1, locked and true or false
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function PickupSlot(bag, slot)
|
|
local fn = (C_Container and C_Container.PickupContainerItem) or PickupContainerItem
|
|
if type(fn) == "function" then
|
|
fn(bag, slot)
|
|
end
|
|
end
|
|
|
|
local function BagCount()
|
|
return tonumber(_G.NUM_BAG_SLOTS) or 4
|
|
end
|
|
|
|
local function CursorItemID()
|
|
if not GetCursorInfo then
|
|
return nil
|
|
end
|
|
local kind, id = GetCursorInfo()
|
|
if kind == "item" then
|
|
return tonumber(id)
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function CursorHasAnything()
|
|
return GetCursorInfo and GetCursorInfo() ~= nil
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Blacklist
|
|
------------------------------------------------------------------------
|
|
|
|
function Loot:IsListed(itemID)
|
|
return itemID ~= nil and self.chardb.blacklist[itemID] ~= nil
|
|
end
|
|
|
|
function Loot:Count()
|
|
local n = 0
|
|
for _ in pairs(self.chardb.blacklist) do
|
|
n = n + 1
|
|
end
|
|
return n
|
|
end
|
|
|
|
-- Item-ID aus Zahl, Text oder Item-Link; nil bei Unsinn
|
|
function Loot.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
|
|
|
|
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
|
|
|
|
-- Rueckgabe: true oder false, Grund ("invalid" | "already" | "full")
|
|
function Loot:Add(input)
|
|
local id = self.ParseItem(input)
|
|
if not id then
|
|
return false, "invalid"
|
|
end
|
|
if self.chardb.blacklist[id] then
|
|
return false, "already"
|
|
end
|
|
if self:Count() >= MAX_LIST then
|
|
return false, "full"
|
|
end
|
|
local name = ItemName(id)
|
|
self.chardb.blacklist[id] = { name = name and name:sub(1, 80) or nil, added = time() }
|
|
FW:Fire("LOOT_LIST_CHANGED")
|
|
self:Scan()
|
|
return true, id
|
|
end
|
|
|
|
function Loot:Remove(itemID)
|
|
if self.chardb.blacklist[itemID] then
|
|
self.chardb.blacklist[itemID] = nil
|
|
FW:Fire("LOOT_LIST_CHANGED")
|
|
self:Scan()
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Sortierte Liste { { id, name } } (nach Name, unbekannte ans Ende)
|
|
function Loot:List()
|
|
local list = {}
|
|
for id, entry in pairs(self.chardb.blacklist) do
|
|
local name = ItemName(id) or entry.name
|
|
if name and entry.name ~= name then
|
|
entry.name = name:sub(1, 80)
|
|
end
|
|
list[#list + 1] = { id = id, name = name }
|
|
end
|
|
table.sort(list, 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 list
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Zuletzt erhalten, Protokoll
|
|
------------------------------------------------------------------------
|
|
|
|
function Loot:NoteLooted(itemID)
|
|
if not itemID then
|
|
return
|
|
end
|
|
local recent = self.chardb.recent
|
|
for i = #recent, 1, -1 do
|
|
if recent[i].id == itemID then
|
|
table.remove(recent, i)
|
|
end
|
|
end
|
|
table.insert(recent, 1, { id = itemID, t = time() })
|
|
while #recent > MAX_RECENT do
|
|
table.remove(recent)
|
|
end
|
|
FW:Fire("LOOT_RECENT_CHANGED")
|
|
end
|
|
|
|
local function AddLog(self, itemID, count)
|
|
local log = self.chardb.log
|
|
local name = ItemName(itemID) or (self.chardb.blacklist[itemID] and self.chardb.blacklist[itemID].name) or ("#" .. itemID)
|
|
table.insert(log, 1, { id = itemID, name = name:sub(1, 80), count = count or 1, t = time() })
|
|
while #log > MAX_LOG do
|
|
table.remove(log)
|
|
end
|
|
return name
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Taschen durchsuchen
|
|
------------------------------------------------------------------------
|
|
|
|
-- Fruehere Versionen (<= 0.1.1) legten die Blacklist Account-weit ab. Jeder Charakter
|
|
-- uebernimmt diese alte Liste einmalig als Kopie, danach sind die Listen unabhaengig.
|
|
function Loot:OnInitialize()
|
|
local old = self.db.blacklist
|
|
if type(old) == "table" and not self.chardb.migrated then
|
|
self.chardb.migrated = true
|
|
for id, entry in pairs(old) do
|
|
if self.chardb.blacklist[id] == nil and type(entry) == "table" then
|
|
self.chardb.blacklist[id] = { name = entry.name, added = entry.added }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function Loot:Scan()
|
|
local list = {}
|
|
if self.chardb then
|
|
for bag = 0, BagCount() do
|
|
for slot = 1, NumSlots(bag) do
|
|
local id, count = SlotItem(bag, slot)
|
|
if id and self.chardb.blacklist[id] then
|
|
list[#list + 1] = { bag = bag, slot = slot, id = id, count = count }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local changed = #list ~= #self.pending
|
|
self.pending = list
|
|
if changed then
|
|
FW:Fire("LOOT_PENDING_CHANGED", #list)
|
|
end
|
|
return #list
|
|
end
|
|
|
|
function Loot:PendingCount()
|
|
return #self.pending
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Loeschen (nur im Moment einer Mausaktion in der Spielwelt)
|
|
------------------------------------------------------------------------
|
|
|
|
function Loot:CanDelete()
|
|
local s = self.db and self.db.settings
|
|
if not s or not s.enabled or self.blocked then
|
|
return false
|
|
end
|
|
if InCombatLockdown and InCombatLockdown() then
|
|
return false
|
|
end
|
|
if CursorHasAnything() then
|
|
return false
|
|
end
|
|
if _G.LootFrame and _G.LootFrame.IsShown and _G.LootFrame:IsShown() then
|
|
return false
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Loescht ein wartendes Item. Rueckgabe: true bei Erfolg, sonst false und Grund.
|
|
function Loot:DeleteOne()
|
|
if not self:CanDelete() then
|
|
return false, "blocked"
|
|
end
|
|
-- Frisch suchen, falls sich die Taschen seit dem letzten Durchlauf geaendert haben
|
|
self:Scan()
|
|
local entry = self.pending[1]
|
|
if not entry then
|
|
return false, "none"
|
|
end
|
|
local id, count, locked = SlotItem(entry.bag, entry.slot)
|
|
if id ~= entry.id or locked or not self.chardb.blacklist[id] then
|
|
return false, "changed"
|
|
end
|
|
|
|
PickupSlot(entry.bag, entry.slot)
|
|
if CursorItemID() ~= entry.id then
|
|
if CursorHasAnything() then
|
|
ClearCursor()
|
|
end
|
|
return false, "pickup"
|
|
end
|
|
|
|
local fn = _G.DeleteCursorItem
|
|
if type(fn) ~= "function" then
|
|
ClearCursor()
|
|
return false, "noapi"
|
|
end
|
|
pcall(fn)
|
|
|
|
if CursorHasAnything() then
|
|
-- Das Spiel hat das Loeschen nicht ausgefuehrt
|
|
ClearCursor()
|
|
self.failures = self.failures + 1
|
|
if self.failures >= MAX_FAILURES and not self.blocked then
|
|
self.blocked = true
|
|
FW:Print(FW.L.LOOT_BLOCKED)
|
|
end
|
|
return false, "denied"
|
|
end
|
|
|
|
self.failures = 0
|
|
local name = AddLog(self, entry.id, count)
|
|
if self.db.settings.chat then
|
|
FW:Print(string.format(FW.L.LOOT_DELETED, name, count or 1))
|
|
end
|
|
FW:Fire("LOOT_LOG_CHANGED")
|
|
self:Scan()
|
|
return true
|
|
end
|
|
|
|
local lastClick = 0
|
|
function Loot:OnWorldClick()
|
|
if #self.pending == 0 then
|
|
return
|
|
end
|
|
local now = GetTime and GetTime() or 0
|
|
if now - lastClick < 0.15 then
|
|
return
|
|
end
|
|
lastClick = now
|
|
self:DeleteOne()
|
|
end
|
|
|
|
function Loot:InstallHook()
|
|
if self.hooked then
|
|
return true
|
|
end
|
|
if _G.WorldFrame and _G.WorldFrame.HookScript then
|
|
_G.WorldFrame:HookScript("OnMouseUp", function()
|
|
Loot:OnWorldClick()
|
|
end)
|
|
self.hooked = true
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Beute
|
|
------------------------------------------------------------------------
|
|
|
|
function Loot:OnLootOpened()
|
|
pcall(self.LootOpenedImpl, self)
|
|
end
|
|
|
|
function Loot:LootOpenedImpl()
|
|
if not GetNumLootItems then
|
|
return
|
|
end
|
|
local autoLoot = self.db.settings.autoLoot
|
|
for slot = GetNumLootItems(), 1, -1 do
|
|
local link = GetLootSlotLink and Util.Clean(GetLootSlotLink(slot))
|
|
local id = type(link) == "string" and tonumber(link:match("item:(%d+)")) or nil
|
|
if id then
|
|
self:NoteLooted(id)
|
|
if autoLoot and self.chardb.blacklist[id] and (not LootSlotHasItem or LootSlotHasItem(slot)) then
|
|
pcall(LootSlot, slot)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function Loot:OnChatLoot(text, ...)
|
|
-- Geheime Werte (secret values) werden ignoriert
|
|
pcall(function(...)
|
|
if Util.IsSecret(text) or type(text) ~= "string" then
|
|
return
|
|
end
|
|
local guid = select(11, ...)
|
|
if Util.IsSecret(guid) then
|
|
return
|
|
end
|
|
if guid and UnitGUID and guid ~= UnitGUID("player") then
|
|
return
|
|
end
|
|
local id = tonumber(text:match("item:(%d+)"))
|
|
if id then
|
|
self:NoteLooted(id)
|
|
end
|
|
end, ...)
|
|
end
|
|
|
|
function Loot:OnBagUpdate()
|
|
self:Scan()
|
|
end
|
|
|
|
function Loot:OnEnable()
|
|
self:InstallHook()
|
|
FW:RegisterEvent("LOOT_OPENED", self.OnLootOpened, self)
|
|
FW:RegisterEvent("CHAT_MSG_LOOT", self.OnChatLoot, self)
|
|
FW:RegisterEvent("BAG_UPDATE_DELAYED", self.OnBagUpdate, self)
|
|
FW:RegisterEvent("PLAYER_ENTERING_WORLD", self.OnBagUpdate, self)
|
|
if C_Timer and C_Timer.After then
|
|
C_Timer.After(2, function()
|
|
Loot:Scan()
|
|
end)
|
|
end
|
|
end
|
|
|
|
function Loot:OnDisable()
|
|
for _, event in ipairs({ "LOOT_OPENED", "CHAT_MSG_LOOT", "BAG_UPDATE_DELAYED", "PLAYER_ENTERING_WORLD" }) do
|
|
FW:UnregisterEvent(event, self)
|
|
end
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Export / Import der Blacklist
|
|
------------------------------------------------------------------------
|
|
|
|
function Loot:ExportData()
|
|
local list = {}
|
|
for _, e in ipairs(self:List()) do
|
|
list[#list + 1] = { id = e.id, name = e.name }
|
|
end
|
|
if #list == 0 then
|
|
return {}
|
|
end
|
|
return { list = list }
|
|
end
|
|
|
|
function Loot:ValidateImport(data)
|
|
if type(data) ~= "table" or type(data.list) ~= "table" or #data.list == 0 then
|
|
return false, L.LOOT_IMPORT_EMPTY
|
|
end
|
|
if #data.list > MAX_IMPORT then
|
|
return false, L.LOOT_IMPORT_TOO_MANY
|
|
end
|
|
return true
|
|
end
|
|
|
|
function Loot:ImportData(data)
|
|
local added, known = 0, 0
|
|
for _, e in ipairs(data.list or {}) do
|
|
local id = type(e) == "table" and self.ParseItem(e.id) or nil
|
|
if id then
|
|
if self.chardb.blacklist[id] then
|
|
known = known + 1
|
|
elseif self:Count() < MAX_LIST then
|
|
local name = type(e.name) == "string" and e.name:sub(1, 80) or ItemName(id)
|
|
self.chardb.blacklist[id] = { name = name, added = time() }
|
|
added = added + 1
|
|
end
|
|
end
|
|
end
|
|
FW:Fire("LOOT_LIST_CHANGED")
|
|
self:Scan()
|
|
return string.format(FW.L.LOOT_IMPORT_STATS, added, known)
|
|
end
|
|
|
|
if FW.Export and FW.Export.RegisterHandler then
|
|
FW.Export.RegisterHandler("lootlist", {
|
|
label = function()
|
|
return FW.L.LOOT_EXPORT_LABEL
|
|
end,
|
|
export = function()
|
|
return Loot:ExportData()
|
|
end,
|
|
validate = function(data)
|
|
return Loot:ValidateImport(data)
|
|
end,
|
|
import = function(data)
|
|
return Loot:ImportData(data)
|
|
end,
|
|
})
|
|
end
|
|
|
|
------------------------------------------------------------------------
|
|
-- Test im Spiel: /fwloot test
|
|
------------------------------------------------------------------------
|
|
|
|
function Loot:RunTest()
|
|
local Lc = FW.L
|
|
local function yn(v)
|
|
return v and Lc.LOOT_YES or Lc.LOOT_NO
|
|
end
|
|
FW:Print(string.format(Lc.LOOT_TEST_API,
|
|
yn(C_Container and C_Container.PickupContainerItem or PickupContainerItem),
|
|
yn(type(_G.DeleteCursorItem) == "function"),
|
|
yn(self.hooked)))
|
|
if self.blocked then
|
|
self.blocked = false
|
|
self.failures = 0
|
|
FW:Print(Lc.LOOT_TEST_RESET)
|
|
end
|
|
local n = self:Scan()
|
|
if n == 0 then
|
|
FW:Print(Lc.LOOT_TEST_NONE)
|
|
else
|
|
FW:Print(string.format(Lc.LOOT_TEST_PENDING, n))
|
|
end
|
|
end
|