TradeSkillMaster/LibTSM/Service/AuctionTracking.lua

627 lines
22 KiB
Lua
Raw Normal View History

2020-11-13 14:13:12 -05:00
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local AuctionTracking = TSM.Init("Service.AuctionTracking")
local L = TSM.Include("Locale").GetTable()
local Database = TSM.Include("Util.Database")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local TempTable = TSM.Include("Util.TempTable")
local Sound = TSM.Include("Util.Sound")
local Money = TSM.Include("Util.Money")
local Analytics = TSM.Include("Util.Analytics")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Settings = TSM.Include("Service.Settings")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local private = {
settings = nil,
indexDB = nil,
quantityDB = nil,
updateQuery = nil, -- luacheck: ignore 1004 - just stored for GC reasons
isAHOpen = false,
callbacks = {},
expiresCallbacks = {},
indexUpdates = {
list = {},
pending = {},
},
cancelAuctionId = nil,
lastScanNum = nil,
ignoreUpdateEvent = nil,
lastPurchase = {},
prevLineId = nil,
prevLineResult = nil,
origChatFrameOnEvent = nil,
pendingFuture = nil,
auctionIdToLink = {},
auctionIdToItemBuyout = {},
prevLogTime = 0,
prevLogNum = math.huge,
}
local PLAYER_NAME = UnitName("player")
local SALE_HINT_SEP = "\001"
local SALE_HINT_EXPIRE_TIME = 33 * 24 * 60 * 60
local SORT_ORDER = not TSM.IsWowClassic() and {
{ sortOrder = Enum.AuctionHouseSortOrder.Name, reverseSort = false },
{ sortOrder = Enum.AuctionHouseSortOrder.Price, reverseSort = false },
}
local AUCTIONABLE_WOW_TOKEN_ITEM_ID = 122270
-- ============================================================================
-- Module Loading
-- ============================================================================
AuctionTracking:OnSettingsLoad(function()
private.settings = Settings.NewView()
:AddKey("char", "internalData", "auctionSaleHints")
:AddKey("char", "internalData", "auctionPrices")
:AddKey("char", "internalData", "auctionMessages")
:AddKey("factionrealm", "internalData", "expiringAuction")
:AddKey("sync", "internalData", "auctionQuantity")
:AddKey("global", "coreOptions", "auctionSaleSound")
Event.Register("AUCTION_HOUSE_SHOW", private.AuctionHouseShowHandler)
Event.Register("AUCTION_HOUSE_CLOSED", private.AuctionHouseClosedHandler)
if TSM.IsWowClassic() then
Event.Register("AUCTION_OWNED_LIST_UPDATE", private.AuctionOwnedListUpdateHandler)
else
Event.Register("OWNED_AUCTIONS_UPDATED", private.AuctionOwnedListUpdateHandler)
Event.Register("AUCTION_CANCELED", private.AuctionCanceledHandler)
end
private.indexDB = Database.NewSchema("AUCTION_TRACKING_INDEXES")
:AddUniqueNumberField("index")
:AddStringField("itemString")
:AddSmartMapField("baseItemString", ItemString.GetBaseMap(), "itemString")
:AddStringField("itemLink")
:AddNumberField("itemTexture")
:AddStringField("itemName")
:AddNumberField("itemQuality")
:AddNumberField("duration")
:AddStringField("highBidder")
:AddNumberField("currentBid")
:AddNumberField("buyout")
:AddNumberField("stackSize")
:AddNumberField("saleStatus")
:AddNumberField("auctionId")
:AddIndex("index")
:AddIndex("saleStatus")
:AddIndex("auctionId")
:Commit()
private.quantityDB = Database.NewSchema("AUCTION_TRACKING_QUANTITY")
:AddUniqueStringField("itemString")
:AddNumberField("quantity")
:Commit()
private.updateQuery = private.indexDB:NewQuery()
:SetUpdateCallback(private.OnCallbackQueryUpdated)
private.RebuildQuantityDB()
for info, timestamp in pairs(private.settings.auctionSaleHints) do
if time() > timestamp + SALE_HINT_EXPIRE_TIME then
private.settings.auctionSaleHints[info] = nil
end
end
if TSM.IsWowClassic() then
hooksecurefunc("PostAuction", function(_, _, duration)
private.PostAuctionHookHandler(duration)
end)
else
hooksecurefunc(C_AuctionHouse, "PostCommodity", function(_, duration)
private.PostAuctionHookHandler(duration)
end)
hooksecurefunc(C_AuctionHouse, "PostItem", function(_, duration)
private.PostAuctionHookHandler(duration)
end)
hooksecurefunc(C_AuctionHouse, "CancelAuction", function(auctionId)
private.cancelAuctionId = auctionId
end)
end
-- setup enhanced sale / buy messages
ChatFrame_AddMessageEventFilter("CHAT_MSG_SYSTEM", private.FilterSystemMsg)
if TSM.IsWowClassic() then
hooksecurefunc("PlaceAuctionBid", function(_, index, amountPaid)
local link = GetAuctionItemLink("list", index)
local name, _, stackSize, _, _, _, _, _, _, buyout = GetAuctionItemInfo("list", index)
if amountPaid == buyout then
wipe(private.lastPurchase)
private.lastPurchase.name = name
private.lastPurchase.link = link
private.lastPurchase.stackSize = stackSize
private.lastPurchase.buyout = buyout
end
end)
else
Event.Register("ITEM_SEARCH_RESULTS_UPDATED", function(_, itemKey)
wipe(private.auctionIdToLink)
wipe(private.auctionIdToItemBuyout)
for i = 1, C_AuctionHouse.GetNumItemSearchResults(itemKey) do
local info = C_AuctionHouse.GetItemSearchResultInfo(itemKey, i)
if info.buyoutAmount then
private.auctionIdToLink[info.auctionID] = info.itemLink
private.auctionIdToItemBuyout[info.auctionID] = info.buyoutAmount
end
end
end)
hooksecurefunc(C_AuctionHouse, "PlaceBid", function(auctionId, bidPlaced)
local link = private.auctionIdToLink[auctionId]
local buyout = private.auctionIdToItemBuyout[auctionId]
if not link or buyout ~= bidPlaced then
return
end
wipe(private.lastPurchase)
private.lastPurchase.name = ItemInfo.GetName(link)
private.lastPurchase.link = link
private.lastPurchase.stackSize = 1
private.lastPurchase.buyout = bidPlaced
end)
hooksecurefunc(C_AuctionHouse, "ConfirmCommoditiesPurchase", function(itemId, quantity)
local link = ItemInfo.GetLink("i:"..itemId)
if not link then
return
end
local origQuantity = quantity
local buyout = 0
for i = 1, C_AuctionHouse.GetNumCommoditySearchResults(itemId) do
local info = C_AuctionHouse.GetCommoditySearchResultInfo(itemId, i)
local resultQuantity = min(quantity, info.quantity - info.numOwnerItems)
buyout = buyout + resultQuantity * info.unitPrice
quantity = quantity - resultQuantity
if quantity == 0 then
break
end
end
if quantity > 0 then
return
end
private.lastPurchase.name = ItemInfo.GetName(link)
private.lastPurchase.link = link
private.lastPurchase.stackSize = origQuantity
private.lastPurchase.buyout = buyout
end)
end
end)
AuctionTracking:OnGameDataLoad(function()
-- setup auction created / cancelled filtering
-- NOTE: this is delayed until the game is loaded to avoid taint issues
local ElvUIChat, ElvUIChatIsEnabled = nil, nil
if IsAddOnLoaded("ElvUI") and ElvUI then
ElvUIChat = ElvUI[1]:GetModule("Chat")
if ElvUI[3].chat.enable then
ElvUIChatIsEnabled = true
end
end
if ElvUIChatIsEnabled then
private.origChatFrameOnEvent = ElvUIChat.ChatFrame_OnEvent
ElvUIChat.ChatFrame_OnEvent = private.ChatFrameOnEvent
else
private.origChatFrameOnEvent = ChatFrame_OnEvent
ChatFrame_OnEvent = private.ChatFrameOnEvent
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function AuctionTracking.RegisterCallback(callback)
tinsert(private.callbacks, callback)
end
function AuctionTracking.RegisterExpiresCallback(callback)
tinsert(private.expiresCallbacks, callback)
end
function AuctionTracking.DatabaseFieldIterator()
return private.indexDB:FieldIterator()
end
function AuctionTracking.BaseItemIterator()
return private.quantityDB:NewQuery()
:Select("itemString")
:IteratorAndRelease()
end
function AuctionTracking.CreateQuery()
return private.indexDB:NewQuery()
end
function AuctionTracking.CreateQueryUnsold()
return AuctionTracking.CreateQuery()
:Equal("saleStatus", 0)
end
function AuctionTracking.CreateQueryUnsoldItem(itemString)
return AuctionTracking.CreateQueryUnsold()
:Equal(itemString == ItemString.GetBaseFast(itemString) and "baseItemString" or "itemString", itemString)
end
function AuctionTracking.GetSaleHintItemString(name, stackSize, buyout)
for info in pairs(private.settings.auctionSaleHints) do
local infoName, itemString, infoStackSize, infoBuyout = strsplit(SALE_HINT_SEP, info)
if infoName == name and tonumber(infoStackSize) == stackSize and tonumber(infoBuyout) == buyout then
return itemString
end
end
end
function AuctionTracking.GetQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "quantity") or 0
end
function AuctionTracking.QueryOwnedAuctions()
if not private.isAHOpen then
return
end
if TSM.IsWowClassic() then
GetOwnerAuctionItems()
else
if private.pendingFuture then
return
end
private.pendingFuture = AuctionHouseWrapper.QueryOwnedAuctions(SORT_ORDER)
if not private.pendingFuture then
Delay.AfterTime(0.5, AuctionTracking.QueryOwnedAuctions)
return
end
private.pendingFuture:SetScript("OnDone", private.PendingFutureOnDone)
end
end
-- ============================================================================
-- Event Handlers
-- ============================================================================
function private.AuctionHouseShowHandler()
private.isAHOpen = true
if TSM.IsWowClassic() then
AuctionTracking.QueryOwnedAuctions()
-- We don't always get AUCTION_OWNED_LIST_UPDATE events, so do our own scanning if needed
Delay.AfterTime("AUCTION_BACKGROUND_SCAN", 1, private.DoBackgroundScan, 1)
else
Delay.AfterTime(0.1, AuctionTracking.QueryOwnedAuctions)
end
end
function private.AuctionHouseClosedHandler()
private.isAHOpen = false
Delay.Cancel("AUCTION_BACKGROUND_SCAN")
end
function private.DoBackgroundScan()
if private.GetNumOwnedAuctions() ~= private.lastScanNum then
private.AuctionOwnedListUpdateHandler()
end
end
function private.AuctionOwnedListUpdateHandler()
if private.ignoreUpdateEvent then
return
end
wipe(private.indexUpdates.pending)
wipe(private.indexUpdates.list)
local numOwned = private.GetNumOwnedAuctions()
for i = 1, numOwned do
if not private.indexUpdates.pending[i] then
private.indexUpdates.pending[i] = true
tinsert(private.indexUpdates.list, i)
end
end
if numOwned == 0 and private.settings.expiringAuction[PLAYER_NAME] then
private.settings.expiringAuction[PLAYER_NAME] = nil
for _, callback in ipairs(private.expiresCallbacks) do
callback()
end
end
Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 2, private.AuctionOwnedListUpdateDelayed)
end
function private.AuctionCanceledHandler(_, auctionId)
if not private.cancelAuctionId or auctionId ~= 0 then
-- an auction was bought, so rescan the owned auctions
AuctionTracking.QueryOwnedAuctions()
return
end
local row = private.indexDB:NewQuery()
:Equal("auctionId", private.cancelAuctionId)
:GetFirstResultAndRelease()
private.cancelAuctionId = nil
if not row then
return
end
local baseItemString = row:GetField("baseItemString")
local stackSize = row:GetField("stackSize")
assert(stackSize <= private.settings.auctionQuantity[baseItemString])
private.settings.auctionQuantity[baseItemString] = private.settings.auctionQuantity[baseItemString] - stackSize
private.RebuildQuantityDB()
private.indexDB:DeleteRow(row)
row:Release()
end
function private.AuctionOwnedListUpdateDelayed()
if not private.isAHOpen then
return
elseif AuctionFrame and AuctionFrame:IsVisible() and AuctionFrame.selectedTab == 3 then
-- default UI auctions tab is visible, so scan later
Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 2, private.AuctionOwnedListUpdateDelayed)
return
elseif not TSM.IsWowClassic() and not C_AuctionHouse.HasFullOwnedAuctionResults() then
-- don't have all the results yet, so try again in a moment
Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 0.1, private.AuctionOwnedListUpdateDelayed)
return
end
if TSM.IsWowClassic() then
-- check if we need to change the sort
local needsSort = false
local numColumns = #AuctionSort.owner_duration
for i, info in ipairs(AuctionSort.owner_duration) do
local col, reversed = GetAuctionSort("owner", numColumns - i + 1)
-- we want to do the opposite order
reversed = not reversed
if col ~= info.column or info.reverse ~= reversed then
needsSort = true
break
end
end
if needsSort then
Log.Info("Sorting owner auctions")
-- ignore events while changing the sort
private.ignoreUpdateEvent = true
AuctionFrame_SetSort("owner", "duration", true)
SortAuctionApplySort("owner")
private.ignoreUpdateEvent = nil
end
end
-- scan the auctions
local shouldLog = GetTime() - private.prevLogTime > 5
if shouldLog then
private.prevLogTime = GetTime()
end
wipe(private.settings.auctionQuantity)
private.indexDB:TruncateAndBulkInsertStart()
local expire = math.huge
for i = #private.indexUpdates.list, 1, -1 do
local index = private.indexUpdates.list[i]
local auctionId, link, name, texture, stackSize, quality, minBid, buyout, bid, highBidder, saleStatus, duration, shouldIgnore = private.GetOwnedAuctionInfo(index)
if shouldIgnore then
private.indexUpdates.pending[index] = nil
tremove(private.indexUpdates.list, i)
else
name = name or ItemInfo.GetName(link)
texture = texture or ItemInfo.GetTexture(link)
quality = quality or ItemInfo.GetQuality(link)
if link and name and texture and quality then
assert(saleStatus == 0 or saleStatus == 1)
highBidder = highBidder or ""
local itemString = ItemString.Get(link)
local currentBid = highBidder ~= "" and bid or minBid
if not currentBid and saleStatus == 1 and not TSM.IsWowClassic() then
-- sometimes wow doesn't tell us the current bid on sold auctions on retail
currentBid = 0
end
if saleStatus == 0 then
if TSM.IsWowClassic() then
if duration == 1 then -- 30 min
expire = min(expire, time() + 0.5 * 60 * 60)
elseif duration == 2 then -- 2 hours
expire = min(expire, time() + 2 * 60 * 60)
elseif duration == 3 then -- 12 hours
expire = min(expire, time() + 12 * 60 * 60)
end
else
duration = time() + duration
expire = min(expire, duration)
end
local baseItemString = ItemString.GetBaseFast(itemString)
private.settings.auctionQuantity[baseItemString] = (private.settings.auctionQuantity[baseItemString] or 0) + stackSize
local hintInfo = strjoin(SALE_HINT_SEP, ItemInfo.GetName(link), itemString, stackSize, buyout)
private.settings.auctionSaleHints[hintInfo] = time()
else
duration = time() + duration
end
private.indexUpdates.pending[index] = nil
tremove(private.indexUpdates.list, i)
private.indexDB:BulkInsertNewRow(index, itemString, link, texture, name, quality, duration, highBidder, currentBid, buyout, stackSize, saleStatus, auctionId)
elseif shouldLog then
Log.Warn("Missing info (%s, %s, %s, %s)", gsub(tostring(link), "\124", "\\124"), tostring(name), tostring(texture), tostring(quality))
if link and strmatch(link, "item:") and not TSM.IsWowClassic() then
Analytics.Action("AUCTION_TRACKING_MISSING_INFO", link)
end
end
end
end
private.RebuildQuantityDB()
private.indexDB:BulkInsertEnd()
if expire ~= math.huge and (private.settings.expiringAuction[PLAYER_NAME] or math.huge) > expire then
private.settings.expiringAuction[PLAYER_NAME] = expire
for _, callback in ipairs(private.expiresCallbacks) do
callback()
end
end
if shouldLog or #private.indexUpdates.list ~= private.prevLogNum then
Log.Info("Scanned auctions (left=%d)", #private.indexUpdates.list)
private.prevLogNum = #private.indexUpdates.list
end
if #private.indexUpdates.list > 0 then
-- some failed to scan so try again
Delay.AfterFrame("AUCTION_OWNED_LIST_SCAN", 2, private.AuctionOwnedListUpdateDelayed)
else
private.lastScanNum = private.GetNumOwnedAuctions()
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.RebuildQuantityDB()
private.quantityDB:TruncateAndBulkInsertStart()
for itemString, quantity in pairs(private.settings.auctionQuantity) do
if quantity > 0 then
private.quantityDB:BulkInsertNewRow(itemString, quantity)
else
private.settings.auctionQuantity[itemString] = nil
end
end
private.quantityDB:BulkInsertEnd()
end
function private.GetNumOwnedAuctions()
if TSM.IsWowClassic() then
return GetNumAuctionItems("owner")
else
return C_AuctionHouse.GetNumOwnedAuctions()
end
end
function private.GetOwnedAuctionInfo(index)
if TSM.IsWowClassic() then
local name, texture, stackSize, quality, _, _, _, minBid, _, buyout, bid, highBidder, _, _, _, saleStatus = GetAuctionItemInfo("owner", index)
local link = name and name ~= "" and GetAuctionItemLink("owner", index)
if not link then
return
end
local duration = GetAuctionItemTimeLeft("owner", index)
return index, link, name, texture, stackSize, quality, minBid, buyout, bid, highBidder, saleStatus, duration
else
local info = C_AuctionHouse.GetOwnedAuctionInfo(index)
if info.itemKey.itemID == AUCTIONABLE_WOW_TOKEN_ITEM_ID then
-- this is a token, so just ignore it
return nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, true
end
local link = info and info.itemLink
if not link then
return
end
local bid = info.bidAmount or info.buyoutAmount
local minBid = bid
return info.auctionID, link, nil, nil, info.quantity, nil, minBid, info.buyoutAmount or 0, bid, info.bidder or "", info.status, info.timeLeftSeconds
end
end
function private.OnCallbackQueryUpdated()
for _, callback in ipairs(private.callbacks) do
callback()
end
-- updating the auction prices / messages is very low-priority, so throttle it to at most every 0.5 seconds
Delay.AfterTime("UPDATE_AUCTION_PRICES_MESSAGES_THROTTLE", 0.5, private.UpdateAuctionPricesMessages)
end
function private.PostAuctionHookHandler(duration)
local days = nil
if duration == 1 then
days = 0.5
elseif duration == 2 then
days = 1
elseif duration == 3 then
days = 2
end
local expiration = time() + (days * 24 * 60 * 60)
if (private.settings.expiringAuction[PLAYER_NAME] or math.huge) < expiration then
return
end
private.settings.expiringAuction[PLAYER_NAME] = expiration
for _, callback in ipairs(private.expiresCallbacks) do
callback()
end
end
function private.UpdateAuctionPricesMessages()
local INVALID_STACK_SIZE = -1
-- recycle tables from private.settings.auctionPrices if we can so we're not creating a ton of garbage
local freeTables = TempTable.Acquire()
for _, tbl in pairs(private.settings.auctionPrices) do
wipe(tbl)
tinsert(freeTables, tbl)
end
wipe(private.settings.auctionPrices)
wipe(private.settings.auctionMessages)
local auctionPrices = TempTable.Acquire()
local auctionStackSizes = TempTable.Acquire()
local query = AuctionTracking.CreateQueryUnsold()
:Select("itemLink", "stackSize", "buyout")
:GreaterThan("buyout", 0)
:OrderBy("index", true)
for _, link, stackSize, buyout in query:IteratorAndRelease() do
auctionPrices[link] = auctionPrices[link] or tremove(freeTables) or {}
if stackSize ~= auctionStackSizes[link] then
auctionStackSizes[link] = stackSize
end
tinsert(auctionPrices[link], buyout)
end
for link, prices in pairs(auctionPrices) do
local name = ItemInfo.GetName(link)
if auctionStackSizes[link] ~= INVALID_STACK_SIZE then
sort(prices)
private.settings.auctionPrices[link] = prices
private.settings.auctionMessages[format(ERR_AUCTION_SOLD_S, name)] = link
end
end
TempTable.Release(freeTables)
TempTable.Release(auctionPrices)
TempTable.Release(auctionStackSizes)
end
function private.ChatFrameOnEvent(self, event, msg, ...)
-- surpress auction created / cancelled spam
if event == "CHAT_MSG_SYSTEM" and (msg == ERR_AUCTION_STARTED or msg == ERR_AUCTION_REMOVED) then
return
end
return private.origChatFrameOnEvent(self, event, msg, ...)
end
function private.FilterSystemMsg(_, _, msg, ...)
local lineID = select(10, ...)
if lineID ~= private.prevLineId then
private.prevLineId = lineID
private.prevLineResult = nil
local link = private.settings.auctionMessages and private.settings.auctionMessages[msg]
if private.lastPurchase.name and msg == format(ERR_AUCTION_WON_S, private.lastPurchase.name) then
-- we just bought an auction
private.prevLineResult = format(L["You won an auction for %sx%d for %s"], private.lastPurchase.link, private.lastPurchase.stackSize, Money.ToString(private.lastPurchase.buyout, "|cffffffff"))
return nil, private.prevLineResult, ...
elseif link then
-- we may have just sold an auction
local price = tremove(private.settings.auctionPrices[link], 1)
local numAuctions = #private.settings.auctionPrices[link]
if not price then
-- couldn't determine the price, so just replace the link
private.prevLineResult = format(ERR_AUCTION_SOLD_S, link)
Sound.PlaySound(private.settings.auctionSaleSound)
return nil, private.prevLineResult, ...
end
if numAuctions == 0 then -- this was the last auction
private.settings.auctionMessages[msg] = nil
end
private.prevLineResult = format(L["Your auction of %s has sold for %s!"], link, Money.ToString(price, "|cffffffff"))
Sound.PlaySound(private.settings.auctionSaleSound)
return nil, private.prevLineResult, ...
end
end
end
function private.PendingFutureOnDone()
-- we also hook the event, so don't care what the result is
private.pendingFuture:GetValue()
private.pendingFuture = nil
end