initial commit

This commit is contained in:
Gitea
2020-11-13 14:13:12 -05:00
commit 05df49ff60
368 changed files with 128754 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local AltTracking = TSM.Init("Service.AltTracking")
local Database = TSM.Include("Util.Database")
local TempTable = TSM.Include("Util.TempTable")
local Vararg = TSM.Include("Util.Vararg")
local Settings = TSM.Include("Service.Settings")
local private = {
quantityDB = nil,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
AltTracking:OnSettingsLoad(function()
private.quantityDB = Database.NewSchema("INVENTORY_ALT_QUANTITY")
:AddUniqueStringField("itemString")
:AddNumberField("quantity")
:Commit()
local altItemQuantity = TempTable.Acquire()
for _, factionrealm, character, syncScopeKey in Settings.ConnectedFactionrealmAltCharacterIterator() do
for _, key in Vararg.Iterator("bagQuantity", "bankQuantity", "reagentBankQuantity", "auctionQuantity", "mailQuantity") do
for itemString, quantity in pairs(Settings.Get("sync", syncScopeKey, "internalData", key)) do
altItemQuantity[itemString] = (altItemQuantity[itemString] or 0) + quantity
end
end
local pendingMailLookup = Settings.Get("factionrealm", factionrealm, "internalData", "pendingMail")
local pendingMail = pendingMailLookup[character]
local isValid = true
if pendingMail then
for itemString, quantity in pairs(pendingMail) do
if type(quantity) ~= "number" then
isValid = false
break
end
altItemQuantity[itemString] = (altItemQuantity[itemString] or 0) + quantity
end
end
if not isValid then
pendingMailLookup[character] = nil
end
end
private.quantityDB:BulkInsertStart()
for itemString, quantity in pairs(altItemQuantity) do
private.quantityDB:BulkInsertNewRow(itemString, quantity)
end
private.quantityDB:BulkInsertEnd()
TempTable.Release(altItemQuantity)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function AltTracking.BaseItemIterator()
return private.quantityDB:NewQuery()
:Select("itemString")
:IteratorAndRelease()
end
function AltTracking.GetQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "quantity") or 0
end

View File

@@ -0,0 +1,734 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local AuctionHouseWrapper = TSM.Init("Service.AuctionHouseWrapper")
local LibTSMClass = TSM.Include("LibTSMClass")
local Log = TSM.Include("Util.Log")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local Table = TSM.Include("Util.Table")
local Future = TSM.Include("Util.Future")
local Vararg = TSM.Include("Util.Vararg")
local Analytics = TSM.Include("Util.Analytics")
local Math = TSM.Include("Util.Math")
local Debug = TSM.Include("Util.Debug")
local APIWrapper = LibTSMClass.DefineClass("APIWrapper")
local private = {
wrappers = {},
events = {},
argsTemp = {},
sortsPartsTemp = {},
itemKeyPartsTemp = {},
searchQueryAPITimes = {},
isAHOpen = false,
lastResponseReceived = 0,
hookedTime = {},
lastAuctionCanceledAuctionId = nil,
lastAuctionCanceledTime = 0,
auctionIdUpdateCallbacks = {},
}
local API_TIMEOUT = 5
local GET_ALL_TIMEOUT = 30
local SEARCH_QUERY_THROTTLE_INTERVAL = 60
local SEARCH_QUERY_THROTTLE_MAX = 100
local EMPTY_SORTS_TABLE = {}
local ITEM_KEY_KEYS = {
"itemID",
"itemLevel",
"itemSuffix",
"battlePetSpeciesID",
}
local SILENT_EVENTS = {
AUCTION_ITEM_LIST_UPDATE = true,
REPLICATE_ITEM_LIST_UPDATE = true,
}
local GENERIC_EVENTS = {
CHAT_MSG_SYSTEM = 1,
UI_ERROR_MESSAGE = 2,
}
local GENERIC_EVENT_SEP = "/"
local API_EVENT_INFO = TSM.IsWowClassic() and
{ -- Classic
QueryAuctionItems = {
AUCTION_ITEM_LIST_UPDATE = { result = true },
},
} or
{ -- Retail
SendBrowseQuery = {
AUCTION_HOUSE_BROWSE_RESULTS_UPDATED = { result = true },
},
SearchForFavorites = {
AUCTION_HOUSE_BROWSE_RESULTS_UPDATED = { result = true },
},
SearchForItemKeys = {
AUCTION_HOUSE_BROWSE_RESULTS_UPDATED = { result = true },
},
ReplicateItems = {
REPLICATE_ITEM_LIST_UPDATE = { result = true },
},
RequestMoreBrowseResults = {
AUCTION_HOUSE_BROWSE_RESULTS_ADDED = { result = 1 },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { timeoutChange = 1 },
},
SendSearchQuery = {
COMMODITY_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1, apiArgKey = "itemID" },
ITEM_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1 },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { timeoutChange = 1 },
},
SendSellSearchQuery = {
COMMODITY_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1, apiArgKey = "itemID" },
ITEM_SEARCH_RESULTS_UPDATED = { result = true, eventArgIndex = 1, apiArgIndex = 1 },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { timeoutChange = 1 },
},
RequestMoreCommoditySearchResults = {
COMMODITY_SEARCH_RESULTS_ADDED = { result = true },
},
RequestMoreItemSearchResults = {
ITEM_SEARCH_RESULTS_ADDED = { result = true },
},
RefreshCommoditySearchResults = {
COMMODITY_SEARCH_RESULTS_UPDATED = { result = true },
},
RefreshItemSearchResults = {
ITEM_SEARCH_RESULTS_UPDATED = { result = true },
},
QueryOwnedAuctions = {
OWNED_AUCTIONS_UPDATED = { result = true },
},
QueryBids = {
BIDS_UPDATED = { result = true },
},
CancelAuction = {
AUCTION_CANCELED = { result = true, eventArgIndex = 1, apiArgIndex = 1, compareFunc = function(eventArg, apiArg) return eventArg == 0 or apiArg == eventArg end },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
},
StartCommoditiesPurchase = {
COMMODITY_PRICE_UPDATED = { result = 2, rawFilterFunc = function(apiArgs, unitPrice, totalPrice) return Math.Ceil((totalPrice / apiArgs[2]), COPPER_PER_SILVER) == apiArgs[3] and true end },
COMMODITY_PRICE_UNAVAILABLE = { result = false },
},
ConfirmCommoditiesPurchase = {
COMMODITY_PURCHASE_SUCCEEDED = { result = true },
COMMODITY_PURCHASE_FAILED = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_HIGHER_BID] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BID_OWN] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = false },
},
PlaceBid = {
AUCTION_CANCELED = { result = true, eventArgIndex = 1, apiArgIndex = 1 },
["CHAT_MSG_SYSTEM"..GENERIC_EVENT_SEP..ERR_AUCTION_BID_PLACED] = { result = true },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_HIGHER_BID] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BID_OWN] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_MAX_COUNT] = { result = false },
},
PostItem = {
AUCTION_HOUSE_AUCTION_CREATED = { result = true, rawFilterFunc = function(apiArgs) return apiArgs[3] <= 1 end },
AUCTION_MULTISELL_UPDATE = { result = true, rawFilterFunc = function(apiArgs, createdCount, totalToCreate) return createdCount == totalToCreate end },
AUCTION_MULTISELL_FAILURE = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_REPAIR_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_LIMITED_DURATION_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_USED_CHARGES] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_WRAPPED_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BAG] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = nil },
},
PostCommodity = {
AUCTION_HOUSE_AUCTION_CREATED = { result = true },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_ITEM_NOT_FOUND] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_DATABASE_ERROR] = { result = false },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_REPAIR_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_LIMITED_DURATION_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_USED_CHARGES] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_WRAPPED_ITEM] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_AUCTION_BAG] = { result = nil },
["UI_ERROR_MESSAGE"..GENERIC_EVENT_SEP..ERR_NOT_ENOUGH_MONEY] = { result = nil },
},
}
-- ============================================================================
-- Module Loading
-- ============================================================================
AuctionHouseWrapper:OnModuleLoad(function()
Event.Register("AUCTION_HOUSE_SHOW", private.AuctionHouseShowHandler)
Event.Register("AUCTION_HOUSE_CLOSED", private.AuctionHouseClosedHandler)
-- setup wrappers
for apiName in pairs(API_EVENT_INFO) do
private.wrappers[apiName] = APIWrapper(apiName)
end
if not TSM.IsWowClassic() then
-- extra hooks to track search query calls since they are limited
hooksecurefunc(C_AuctionHouse, "SendSearchQuery", function()
tinsert(private.searchQueryAPITimes, GetTime())
end)
hooksecurefunc(C_AuctionHouse, "SendSellSearchQuery", function()
tinsert(private.searchQueryAPITimes, GetTime())
end)
-- events to track auction purchases
Event.Register("AUCTION_CANCELED", private.AuctionCanceledHandler)
Event.Register("ITEM_SEARCH_RESULTS_UPDATED", private.ItemSearchResultsUpdated)
-- general events
Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_RESPONSE_RECEIVED", private.ResponseReceivedHandler)
-- extra events that are interesting to log
Event.Register("AUCTION_HOUSE_NEW_RESULTS_RECEIVED", private.UnusedEventHandler)
Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_DROPPED", private.UnusedEventHandler)
Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_QUEUED", private.UnusedEventHandler)
Event.Register("AUCTION_HOUSE_THROTTLED_MESSAGE_SENT", private.UnusedEventHandler)
if not TSM.IsShadowlands() then
Event.Register("AUCTION_HOUSE_THROTTLED_SPECIFIC_SEARCH_READY", private.UnusedEventHandler)
end
Event.Register("AUCTION_HOUSE_THROTTLED_SYSTEM_READY", private.UnusedEventHandler)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function AuctionHouseWrapper.RegisterAuctionIdUpdateCallback(callback)
tinsert(private.auctionIdUpdateCallbacks, callback)
end
function AuctionHouseWrapper.IsOpen()
return private.isAHOpen
end
function AuctionHouseWrapper.GetAndResetTotalHookedTime()
local total, topTime, topAddon = 0, nil, nil
for addon, hookedTime in pairs(private.hookedTime) do
total = total + hookedTime
if hookedTime > (topTime or 0) then
topTime = hookedTime
topAddon = addon
end
end
wipe(private.hookedTime)
return total, topAddon, topTime
end
function AuctionHouseWrapper.SendBrowseQuery(query)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.SendBrowseQuery:Start(query)
end
function AuctionHouseWrapper.RequestMoreBrowseResults()
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.RequestMoreBrowseResults:Start()
end
function AuctionHouseWrapper.SendSearchQuery(itemKey, isSell)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
-- remove times which are beyond the throttle interval
for i = #private.searchQueryAPITimes, 1, -1 do
if GetTime() - private.searchQueryAPITimes[i] >= SEARCH_QUERY_THROTTLE_INTERVAL then
tremove(private.searchQueryAPITimes, i)
end
end
if #private.searchQueryAPITimes >= SEARCH_QUERY_THROTTLE_MAX then
local delayTime = private.searchQueryAPITimes[1] + SEARCH_QUERY_THROTTLE_INTERVAL - GetTime()
assert(delayTime > 0, "Invalid delay time: "..tostring(delayTime))
Log.Err("Search query can't be run for another %.3f seconds", delayTime)
return nil, delayTime
end
assert(type(isSell) == "boolean")
if isSell then
return private.wrappers.SendSellSearchQuery:Start(itemKey, EMPTY_SORTS_TABLE, true)
else
return private.wrappers.SendSearchQuery:Start(itemKey, EMPTY_SORTS_TABLE, true)
end
end
function AuctionHouseWrapper.RequestMoreCommoditySearchResults(itemId)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.RequestMoreCommoditySearchResults:Start(itemId)
end
function AuctionHouseWrapper.RequestMoreItemSearchResults(itemKey)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.RequestMoreItemSearchResults:Start(itemKey)
end
function AuctionHouseWrapper.QueryOwnedAuctions(sorts)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.QueryOwnedAuctions:Start(sorts)
end
function AuctionHouseWrapper.CancelAuction(auctionId)
assert(not TSM.IsWowClassic())
-- if QueryOwnedAuctions is pending, just cancel it
private.wrappers.QueryOwnedAuctions:CancelIfPending()
if not private.CheckAllIdle() then
return
end
return private.wrappers.CancelAuction:Start(auctionId)
end
function AuctionHouseWrapper.StartCommoditiesPurchase(itemId, quantity, itemBuyout)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.StartCommoditiesPurchase:Start(itemId, quantity, itemBuyout)
end
function AuctionHouseWrapper.ConfirmCommoditiesPurchase(itemId, quantity, totalBuyout)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.ConfirmCommoditiesPurchase:Start(itemId, quantity, totalBuyout)
end
function AuctionHouseWrapper.PlaceBid(auctionId, bidBuyout)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.PlaceBid:Start(auctionId, bidBuyout)
end
function AuctionHouseWrapper.PostItem(itemLocation, postTime, stackSize, bid, buyout)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.PostItem:Start(itemLocation, postTime, stackSize, bid, buyout)
end
function AuctionHouseWrapper.PostCommodity(itemLocation, postTime, stackSize, itemBuyout)
assert(not TSM.IsWowClassic())
if not private.CheckAllIdle() then
return
end
return private.wrappers.PostCommodity:Start(itemLocation, postTime, stackSize, itemBuyout)
end
function AuctionHouseWrapper.QueryAuctionItems(name, minLevel, maxLevel, page, usable, quality, getAll, exact, filterData)
assert(TSM.IsWowClassic())
local canSendQuery, canSendGetAll = CanSendAuctionQuery()
if not canSendQuery or (getAll and not canSendGetAll) or not private.CheckAllIdle() then
return
end
return private.wrappers.QueryAuctionItems:Start(name, minLevel, maxLevel, page, usable, quality, getAll, exact, filterData)
end
-- ============================================================================
-- APIWrapper Class
-- ============================================================================
function APIWrapper.__init(self, name)
self._name = name
self._args = {}
self._state = "IDLE"
self._callTime = nil
self._future = Future.New(self._name.."_FUTURE")
self._future:SetScript("OnCleanup", function()
if self._state == "PENDING_REQUESTED" then
-- switch the current call to a hooked call
self._state = "PENDING_HOOKED"
elseif self._state == "DONE" then
self._state = "IDLE"
end
end)
self._timeoutWrapper = function()
Log.Err("API timed out: %s(%s)", self._name, private.ArgsToStr(unpack(self._args)))
return self:_Done(false)
end
-- hook the API
hooksecurefunc(TSM.IsWowClassic() and _G or C_AuctionHouse, self._name, function(...)
Log.Info("%s(%s)", self._name, private.ArgsToStr(...))
if self:_IsPending() and select("#", ...) == 0 then
return
end
self:CancelIfPending()
if self:_HandleAPICall(...) then
for _, wrapper in pairs(private.wrappers) do
if wrapper ~= self and GetTime() ~= private.lastResponseReceived then
wrapper:CancelIfPending()
end
end
end
end)
-- register related events
for eventName in pairs(API_EVENT_INFO[self._name]) do
private.RegisterForEvent(eventName, self)
end
end
function APIWrapper.IsIdle(self)
return self._state == "IDLE"
end
function APIWrapper.CancelIfPending(self)
if not self:_IsPending() then
return
end
Log.Warn("Canceling pending (%s, %s)", self._name, self._state)
self:_Done(false)
end
function APIWrapper.Start(self, ...)
if self._state ~= "IDLE" then
Log.Err("API already in progress (%s)", self._name)
return
end
self._state = "STARTING"
self:_CallAPI(...)
return self._future
end
function APIWrapper._IsPending(self)
return self._state == "PENDING_REQUESTED" or self._state == "PENDING_HOOKED"
end
function APIWrapper._CallAPI(self, ...)
return (TSM.IsWowClassic() and _G or C_AuctionHouse)[self._name](...)
end
function APIWrapper._HandleAPICall(self, ...)
self._callTime = GetTime()
if self._state == "IDLE" then
self._state = "PENDING_HOOKED"
self._hookAddon = strmatch(Debug.GetStackLevelLocation(3), "AddOns\\([^\\]+)\\")
elseif self._state == "STARTING" then
self._future:Start()
self._state = "PENDING_REQUESTED"
else
error("Unexpected state: "..self._state)
end
Vararg.IntoTable(self._args, ...)
local timeout = nil
if not private.isAHOpen then
timeout = 0
elseif self._name == "QueryAuctionItems" and select(7, ...) then
timeout = GET_ALL_TIMEOUT
else
timeout = API_TIMEOUT
end
Delay.AfterTime(self._name.."_TIMEOUT", timeout, self._timeoutWrapper)
return true
end
function APIWrapper._HandleEvent(self, eventName, ...)
if self._state ~= "PENDING_REQUESTED" and self._state ~= "PENDING_HOOKED" then
return
end
local eventIsValid, result = self:_ValidateEvent(eventName, ...)
if not eventIsValid then
Log.Info("Ignoring invalidated event")
return
end
self:_Done(result)
end
function APIWrapper._ValidateEvent(self, eventName, ...)
local info = nil
if GENERIC_EVENTS[eventName] then
local arg = ...
info = API_EVENT_INFO[self._name][eventName..GENERIC_EVENT_SEP..arg]
else
info = API_EVENT_INFO[self._name][eventName]
end
assert(info)
if info.timeoutChange then
Delay.Cancel(self._name.."_TIMEOUT")
Delay.AfterTime(self._name.."_TIMEOUT", info.timeoutChange, self._timeoutWrapper)
return false
end
local eventIsValid, result = true, nil
if type(info.result) == "number" then
result = select(info.result, ...)
else
result = info.result
end
if info.rawFilterFunc then
if not info.rawFilterFunc(self._args, ...) then
eventIsValid = false
end
elseif info.eventArgIndex then
local eventValue = select(info.eventArgIndex, ...)
local apiValue = self._args[info.apiArgIndex]
if info.apiArgKey then
apiValue = apiValue[info.apiArgKey]
end
local argMatches = nil
assert(type(eventValue) == type(apiValue))
if info.compareFunc then
argMatches = info.compareFunc(eventValue, apiValue)
elseif private.IsItemKey(eventValue) then
argMatches = true
for _, key in ipairs(ITEM_KEY_KEYS) do
if eventValue[key] ~= apiValue[key] then
argMatches = false
break
end
end
elseif type(eventValue) == "table" then
argMatches = Table.Equal(eventValue, apiValue)
else
argMatches = eventValue == apiValue
end
if not argMatches then
eventIsValid = false
end
end
return eventIsValid, result
end
function APIWrapper._Done(self, result)
wipe(self._args)
local hookAddon = self._hookAddon
self._hookAddon = nil
local totalTime = Math.Round((GetTime() - (self._callTime or GetTime())) * 1000)
self._callTime = nil
Delay.Cancel(self._name.."_TIMEOUT")
if self._state == "PENDING_REQUESTED" then
if totalTime > 0 then
Analytics.Action("AH_API_TIME", private.GetAnalyticsRegionRealm(), self._name, result and totalTime or -1)
end
self._state = "DONE"
-- need to do this last as it might trigger another API call or OnCleanup on the future
self._future:Done(result)
elseif self._state == "PENDING_HOOKED" then
self._state = "IDLE"
if hookAddon then
private.hookedTime[hookAddon] = (private.hookedTime[hookAddon] or 0) + totalTime / 1000
end
else
error("Unexpected state: "..self._state)
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.AuctionHouseShowHandler()
private.isAHOpen = true
end
function private.AuctionHouseClosedHandler()
private.isAHOpen = false
for _, wrapper in pairs(private.wrappers) do
wrapper:CancelIfPending()
end
end
function private.IsItemKey(value)
if type(value) ~= "table" then
return false
end
for _, key in ipairs(ITEM_KEY_KEYS) do
if not value[key] then
return false
end
end
return true
end
function private.ItemKeyToStr(itemKey)
assert(#private.itemKeyPartsTemp == 0)
if itemKey.itemID ~= 0 then
tinsert(private.itemKeyPartsTemp, "itemID="..itemKey.itemID)
end
if itemKey.itemLevel ~= 0 then
tinsert(private.itemKeyPartsTemp, "itemLevel="..itemKey.itemLevel)
end
if itemKey.itemSuffix ~= 0 then
tinsert(private.itemKeyPartsTemp, "itemSuffix="..itemKey.itemSuffix)
end
if itemKey.battlePetSpeciesID ~= 0 then
tinsert(private.itemKeyPartsTemp, "battlePetSpeciesID="..itemKey.battlePetSpeciesID)
end
local result = format("{%s}", table.concat(private.itemKeyPartsTemp, ","))
wipe(private.itemKeyPartsTemp)
return result
end
function private.SortsToStr(sorts)
assert(#private.sortsPartsTemp == 0)
for _, sort in ipairs(sorts) do
local name = Table.KeyByValue(Enum.AuctionHouseSortOrder, sort.sortOrder) or "?"
tinsert(private.sortsPartsTemp, format("%s%s", sort.reverseSort and "-" or "", name))
end
local result = format("{%s}", table.concat(private.sortsPartsTemp, ","))
wipe(private.sortsPartsTemp)
return result
end
function private.ArgToStr(arg)
if type(arg) == "table" then
local count = Table.Count(arg)
if private.IsItemKey(arg) then
return private.ItemKeyToStr(arg)
elseif arg.searchString then
return format("{searchString=\"%s\", sorts=%s, minLevel=%s, maxLevel=%s, filters=%s, itemClassFilters=%s}", arg.searchString, private.SortsToStr(arg.sorts), private.ArgToStr(arg.minLevel), private.ArgToStr(arg.maxLevel), private.ArgToStr(arg.filters), private.ArgToStr(arg.itemClassFilters))
elseif arg.IsBagAndSlot then
return format("{<ItemLocation:(%d,%d)>}", arg:GetBagAndSlot())
elseif count == 0 then
return "{}"
elseif count == #arg then
if type(arg[1]) == "table" and arg[1].sortOrder then
return format("{sorts=%s}", private.SortsToStr(arg))
end
return format("{<%d items>}", count)
else
return "{...}"
end
else
return tostring(arg)
end
end
function private.ArgsToStr(...)
assert(#private.argsTemp == 0)
for _, arg in Vararg.Iterator(...) do
tinsert(private.argsTemp, private.ArgToStr(arg))
end
local result = table.concat(private.argsTemp, ",")
wipe(private.argsTemp)
return result
end
function private.RegisterForEvent(eventName, wrapper)
local genericEventArg = nil
eventName, genericEventArg = strsplit(GENERIC_EVENT_SEP, eventName)
if not private.events[eventName] then
private.events[eventName] = {}
Event.Register(eventName, private.EventHandler)
end
if genericEventArg then
private.events[eventName][genericEventArg] = private.events[eventName][genericEventArg] or {}
tinsert(private.events[eventName][genericEventArg], wrapper)
else
tinsert(private.events[eventName], wrapper)
end
end
function private.EventHandler(eventName, ...)
-- reduce the log spam of generic events by combining the message with the name and discarding arguments
local genericEventArg = nil
if eventName == "UI_ERROR_MESSAGE" and select(1, ...) == ERR_AUCTION_DATABASE_ERROR then
-- log an analytics event for "Internal Auction Error" messages
for apiName, wrapper in pairs(private.wrappers) do
if not wrapper:IsIdle() then
Analytics.Action("AH_INTERNAL_ERROR", private.GetAnalyticsRegionRealm(), apiName)
break
end
end
end
if GENERIC_EVENTS[eventName] then
genericEventArg = select(GENERIC_EVENTS[eventName], ...)
assert(genericEventArg)
if not private.events[eventName][genericEventArg] then
return
end
private.EventHandlerHelper(private.events[eventName][genericEventArg], eventName, genericEventArg)
else
private.EventHandlerHelper(private.events[eventName], eventName, ...)
end
end
function private.ResponseReceivedHandler(eventName, ...)
Log.Info("%s (%s)", eventName, private.ArgsToStr(...))
private.lastResponseReceived = GetTime()
end
function private.UnusedEventHandler(eventName, ...)
Log.Info("%s (%s)", eventName, private.ArgsToStr(...))
end
function private.EventHandlerHelper(wrappers, eventName, ...)
if not SILENT_EVENTS[eventName] then
Log.Info("%s (%s)", eventName, private.ArgsToStr(...))
end
for _, wrapper in ipairs(wrappers) do
wrapper:_HandleEvent(eventName, ...)
end
end
function private.CheckAllIdle()
for apiName, wrapper in pairs(private.wrappers) do
if not wrapper:IsIdle() then
Log.Err("Another wrapper is pending (%s)", apiName)
return false
end
end
return true
end
function private.AuctionCanceledHandler(_, auctionId)
private.lastAuctionCanceledAuctionId = auctionId
private.lastAuctionCanceledTime = GetTime()
end
function private.ItemSearchResultsUpdated(_, itemKey, auctionId)
if private.lastAuctionCanceledTime == GetTime() and auctionId then
Log.Info("Auction ID changed from %s to %s", tostring(private.lastAuctionCanceledAuctionId), tostring(auctionId))
local newResultInfo = nil
for i = 1, C_AuctionHouse.GetNumItemSearchResults(itemKey) do
local info = C_AuctionHouse.GetItemSearchResultInfo(itemKey, i)
if info.auctionID == auctionId then
newResultInfo = info
break
end
end
if not newResultInfo then
Log.Warn("Failed to find new result info")
end
for _, callback in ipairs(private.auctionIdUpdateCallbacks) do
callback(private.lastAuctionCanceledAuctionId, auctionId, newResultInfo)
end
private.lastAuctionCanceledAuctionId = nil
private.lastAuctionCanceledTime = 0
end
end
function private.GetAnalyticsRegionRealm()
return TSM.GetRegion().."-"..gsub(GetRealmName(), "\226", "'")
end

View File

@@ -0,0 +1,65 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Auction scanning functions.
-- @module AuctionScan
local _, TSM = ...
local AuctionScan = TSM.Init("Service.AuctionScan")
local ScanManager = TSM.Include("Service.AuctionScanClasses.ScanManager")
-- ============================================================================
-- Module Functions
-- ============================================================================
function AuctionScan.GetManager()
return ScanManager.Get()
end
function AuctionScan.CanBid(subRow)
if not subRow:IsSubRow() then
return false
end
local _, _, _, isHighBidder = subRow:GetBidInfo()
local displayedBid = subRow:GetDisplayedBids()
local buyout = subRow:GetBuyouts()
if isHighBidder then
return false
elseif displayedBid == buyout then
return false
elseif not TSM.IsWowClassic() and subRow:IsCommodity() then
return false
elseif GetMoney() < subRow:GetRequiredBid() then
return false
end
return true
end
function AuctionScan.CanBuyout(subRow, auctionScan)
if not subRow:IsSubRow() then
return false
end
local buyout, itemBuyout = subRow:GetBuyouts()
if not TSM.IsWowClassic() then
buyout = itemBuyout
end
local itemString = subRow:GetItemString()
if buyout == 0 or GetMoney() < buyout then
return false
elseif not TSM.IsWowClassic() and subRow:IsCommodity() then
-- make sure it's the cheapest
local isCheapest = false
for _, query in auctionScan:QueryIterator() do
isCheapest = isCheapest or subRow == query:GetCheapestSubRow(itemString)
end
if not isCheapest then
return false
end
end
return true
end

View File

@@ -0,0 +1,675 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- AuctionQuery Class.
-- A class which is used to build a query to scan the auciton house.
-- @classmod AuctionQuery
local _, TSM = ...
local Query = TSM.Init("Service.AuctionScanClasses.Query")
local String = TSM.Include("Util.String")
local ObjectPool = TSM.Include("Util.ObjectPool")
local ItemString = TSM.Include("Util.ItemString")
local TempTable = TSM.Include("Util.TempTable")
local Table = TSM.Include("Util.Table")
local ItemInfo = TSM.Include("Service.ItemInfo")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local Scanner = TSM.Include("Service.AuctionScanClasses.Scanner")
local LibTSMClass = TSM.Include("LibTSMClass")
local AuctionQuery = LibTSMClass.DefineClass("AuctionQuery")
local private = {
objectPool = ObjectPool.New("AUCTION_SCAN_QUERY", AuctionQuery),
}
local ITEM_SPECIFIC = newproxy()
local ITEM_BASE = newproxy()
local DEFAULT_SORTS = TSM.IsWowClassic() and
{ -- classic
"seller",
"quantity",
"unitprice",
} or
{ -- retail
{ sortOrder = Enum.AuctionHouseSortOrder.Price, reverseSort = false },
{ sortOrder = Enum.AuctionHouseSortOrder.Name, reverseSort = false },
}
local EMPTY_SORTS = {}
local INV_TYPES = {
CHEST = TSM.IsShadowlands() and Enum.InventoryType.IndexChestType or LE_INVENTORY_TYPE_CHEST_TYPE,
ROBE = TSM.IsShadowlands() and Enum.InventoryType.IndexRobeType or LE_INVENTORY_TYPE_ROBE_TYPE,
NECK = TSM.IsShadowlands() and Enum.InventoryType.IndexNeckType or LE_INVENTORY_TYPE_NECK_TYPE,
FINGER = TSM.IsShadowlands() and Enum.InventoryType.IndexFingerType or LE_INVENTORY_TYPE_FINGER_TYPE,
TRINKET = TSM.IsShadowlands() and Enum.InventoryType.IndexTrinketType or LE_INVENTORY_TYPE_TRINKET_TYPE,
HOLDABLE = TSM.IsShadowlands() and Enum.InventoryType.IndexHoldableType or LE_INVENTORY_TYPE_HOLDABLE_TYPE,
BODY = TSM.IsShadowlands() and Enum.InventoryType.IndexBodyType or LE_INVENTORY_TYPE_BODY_TYPE,
CLOAK = TSM.IsShadowlands() and Enum.InventoryType.IndexCloakType or LE_INVENTORY_TYPE_CLOAK_TYPE,
}
assert(Table.Count(INV_TYPES) == 8)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Query.Get()
return private.objectPool:Get()
end
-- ============================================================================
-- Class Meta Methods
-- ============================================================================
function AuctionQuery.__init(self)
self._str = ""
self._strLower = ""
self._strMatch = ""
self._exact = false
self._minQuality = -math.huge
self._maxQuality = math.huge
self._minLevel = -math.huge
self._maxLevel = math.huge
self._minItemLevel = -math.huge
self._maxItemLevel = math.huge
self._class = nil
self._subClass = nil
self._invType = nil
self._classFilter1 = {}
self._classFilter2 = {}
self._usable = false
self._uncollected = false
self._upgrades = false
self._unlearned = false
self._canLearn = false
self._minPrice = 0
self._maxPrice = math.huge
self._items = {}
self._customFilters = {}
self._isBrowseDoneFunc = nil
self._specifiedPage = nil
self._getAll = nil
self._resolveSellers = false
self._callback = nil
self._queryTemp = {}
self._filtersTemp = {}
self._classFiltersTemp = {}
self._browseResults = {}
self._page = 0
end
function AuctionQuery.Release(self)
self._str = ""
self._strLower = ""
self._strMatch = ""
self._exact = false
self._minQuality = -math.huge
self._maxQuality = math.huge
self._minLevel = -math.huge
self._maxLevel = math.huge
self._minItemLevel = -math.huge
self._maxItemLevel = math.huge
self._class = nil
self._subClass = nil
self._invType = nil
wipe(self._classFilter1)
wipe(self._classFilter2)
self._usable = false
self._uncollected = false
self._upgrades = false
self._unlearned = false
self._canLearn = false
self._minPrice = 0
self._maxPrice = math.huge
wipe(self._items)
wipe(self._customFilters)
self._isBrowseDoneFunc = nil
self._specifiedPage = nil
self._getAll = nil
self._resolveSellers = false
self._callback = nil
wipe(self._queryTemp)
wipe(self._filtersTemp)
wipe(self._classFiltersTemp)
for _, row in pairs(self._browseResults) do
row:Release()
end
wipe(self._browseResults)
self._page = 0
private.objectPool:Recycle(self)
end
-- ============================================================================
-- Public Class Methods
-- ============================================================================
function AuctionQuery.SetStr(self, str, exact)
self._str = str or ""
self._strLower = strlower(self._str)
self._strMatch = String.Escape(self._strLower)
self._exact = exact or false
return self
end
function AuctionQuery.SetQualityRange(self, minQuality, maxQuality)
self._minQuality = minQuality or -math.huge
self._maxQuality = maxQuality or math.huge
return self
end
function AuctionQuery.SetLevelRange(self, minLevel, maxLevel)
self._minLevel = minLevel or -math.huge
self._maxLevel = maxLevel or math.huge
return self
end
function AuctionQuery.SetItemLevelRange(self, minItemLevel, maxItemLevel)
self._minItemLevel = minItemLevel or -math.huge
self._maxItemLevel = maxItemLevel or math.huge
return self
end
function AuctionQuery.SetClass(self, class, subClass, invType)
self._class = class or nil
self._subClass = subClass or nil
self._invType = invType or nil
return self
end
function AuctionQuery.SetUsable(self, usable)
self._usable = usable or false
return self
end
function AuctionQuery.SetUncollected(self, uncollected)
self._uncollected = uncollected or false
return self
end
function AuctionQuery.SetUpgrades(self, upgrades)
self._upgrades = upgrades or false
return self
end
function AuctionQuery.SetUnlearned(self, unlearned)
self._unlearned = unlearned or false
return self
end
function AuctionQuery.SetCanLearn(self, canLearn)
self._canLearn = canLearn or false
return self
end
function AuctionQuery.SetPriceRange(self, minPrice, maxPrice)
self._minPrice = minPrice or 0
self._maxPrice = maxPrice or math.huge
return self
end
function AuctionQuery.SetItems(self, items)
wipe(self._items)
if type(items) == "table" then
for _, itemString in ipairs(items) do
local baseItemString = ItemString.GetBaseFast(itemString)
self._items[itemString] = ITEM_SPECIFIC
if baseItemString ~= itemString then
self._items[baseItemString] = self._items[baseItemString] or ITEM_BASE
end
end
elseif type(items) == "string" then
local itemString = items
local baseItemString = ItemString.GetBaseFast(itemString)
self._items[itemString] = ITEM_SPECIFIC
if baseItemString ~= itemString then
self._items[baseItemString] = self._items[baseItemString] or ITEM_BASE
end
elseif items ~= nil then
error("Invalid items type: "..tostring(items))
end
return self
end
function AuctionQuery.AddCustomFilter(self, func)
self._customFilters[func] = true
return self
end
function AuctionQuery.SetIsBrowseDoneFunction(self, func)
self._isBrowseDoneFunc = func
return self
end
function AuctionQuery.SetPage(self, page)
if page == nil then
self._specifiedPage = nil
elseif type(page) == "number" or page == "FIRST" or page == "LAST" then
assert(TSM.IsWowClassic())
self._specifiedPage = page
else
error("Invalid page: "..tostring(page))
end
return self
end
function AuctionQuery.SetGetAll(self, getAll)
-- only currently support GetAll on classic
assert(not getAll or TSM.IsWowClassic())
self._getAll = getAll
return self
end
function AuctionQuery.SetResolveSellers(self, resolveSellers)
self._resolveSellers = resolveSellers
return self
end
function AuctionQuery.SetCallback(self, callback)
self._callback = callback
return self
end
function AuctionQuery.Browse(self, forceNoScan)
assert(not TSM.IsWowClassic() or not forceNoScan)
local noScan = forceNoScan or false
if not TSM.IsWowClassic() then
local numItems = 0
for _, itemType in pairs(self._items) do
if itemType == ITEM_SPECIFIC then
numItems = numItems + 1
end
end
if numItems > 0 and numItems < 500 then
-- it's faster to just issue individual item searches instead of a browse query
noScan = true
end
end
if noScan then
assert(not TSM.IsWowClassic())
local itemKeys = TempTable.Acquire()
for itemString in pairs(self._items) do
if itemString == ItemString.GetBaseFast(itemString) then
local itemId, battlePetSpeciesId = nil, nil
if ItemString.IsPet(itemString) then
itemId = ItemString.ToId(ItemString.GetPetCage())
battlePetSpeciesId = ItemString.ToId(itemString)
else
itemId = ItemString.ToId(itemString)
battlePetSpeciesId = 0
end
local itemKey = C_AuctionHouse.MakeItemKey(itemId, 0, 0, battlePetSpeciesId)
-- FIX for 9.0.1 bug where MakeItemKey randomly adds an itemLevel which breaks scanning
itemKey.itemLevel = 0
tinsert(itemKeys, itemKey)
end
end
local future = Scanner.BrowseNoScan(self, itemKeys, self._browseResults, self._callback)
TempTable.Release(itemKeys)
return future
else
self._page = 0
return Scanner.Browse(self, self._resolveSellers, self._browseResults, self._callback)
end
end
function AuctionQuery.GetSearchProgress(self)
if TSM.IsWowClassic() then
return 1
end
local progress, totalNum = 0, 0
for _, row in pairs(self._browseResults) do
progress = progress + row:_GetSearchProgress()
totalNum = totalNum + 1
end
if totalNum == 0 then
return 0
end
return progress / totalNum
end
function AuctionQuery.GetBrowseResults(self, baseItemString)
return self._browseResults[baseItemString]
end
function AuctionQuery.ItemSubRowIterator(self, itemString)
local result = TempTable.Acquire()
local baseItemString = ItemString.GetBaseFast(itemString)
local isBaseItemString = itemString == baseItemString
local row = self._browseResults[baseItemString]
if row then
for _, subRow in row:SubRowIterator() do
local subRowBaseItemString = subRow:GetBaseItemString()
local subRowItemString = subRow:GetItemString()
if (isBaseItemString and subRowBaseItemString == itemString) or (not isBaseItemString and subRowItemString == itemString) then
tinsert(result, subRow)
end
end
end
return TempTable.Iterator(result)
end
function AuctionQuery.GetCheapestSubRow(self, itemString)
assert(not TSM.IsWowClassic())
local cheapest, cheapestItemBuyout = nil, nil
for _, subRow in self:ItemSubRowIterator(itemString) do
local quantity = subRow:GetQuantities()
local _, numOwnerItems = subRow:GetOwnerInfo()
local _, itemBuyout = subRow:GetBuyouts()
if numOwnerItems ~= quantity and itemBuyout < (cheapestItemBuyout or math.huge) then
cheapest = subRow
cheapestItemBuyout = itemBuyout
end
end
return cheapest
end
function AuctionQuery.BrowseResultsIterator(self)
return pairs(self._browseResults)
end
function AuctionQuery.RemoveResultRow(self, row)
local baseItemString = row:GetBaseItemString()
assert(baseItemString and self._browseResults[baseItemString])
self._browseResults[baseItemString] = nil
row:Release()
if self._callback then
self._callback(self)
end
end
function AuctionQuery.Search(self, row, useCachedData)
assert(not TSM.IsWowClassic())
assert(self._browseResults)
return Scanner.Search(self, self._resolveSellers, useCachedData, row, self._callback)
end
function AuctionQuery.CancelBrowseOrSearch(self)
Scanner.Cancel()
end
function AuctionQuery.ItemIterator(self)
return private.ItemIteratorHelper, self._items, nil
end
function AuctionQuery.WipeBrowseResults(self)
for _, row in pairs(self._browseResults) do
row:Release()
end
wipe(self._browseResults)
if self._callback then
self._callback(self)
end
end
-- ============================================================================
-- Private Class Methods
-- ============================================================================
function AuctionQuery._SetSort(self)
if not TSM.IsWowClassic() then
return true
end
local sorts = (type(self._specifiedPage) == "string" or self._getAll) and EMPTY_SORTS or DEFAULT_SORTS
if GetAuctionSort("list", #sorts + 1) == nil then
local properlySorted = true
for i, col in ipairs(sorts) do
local sortCol, sortReversed = GetAuctionSort("list", #sorts - i + 1)
-- we never care to reverse a sort so if it's reversed then it's not properly sorted
if sortCol ~= col or sortReversed then
properlySorted = false
break
end
end
if properlySorted then
return true
end
end
SortAuctionClearSort("list")
for _, col in ipairs(sorts) do
SortAuctionSetSort("list", col, false)
end
SortAuctionApplySort("list")
return false
end
function AuctionQuery._SendWowQuery(self)
-- build the class filters
wipe(self._classFiltersTemp)
wipe(self._classFilter1)
wipe(self._classFilter2)
if self._invType == INV_TYPES.CHEST or self._invType == INV_TYPES.ROBE then
-- default AH only sends in queries for robe chest type, we need to mimic this when using a chest filter
self._classFilter1.classID = LE_ITEM_CLASS_ARMOR
self._classFilter1.subClassID = self._subClass
self._classFilter1.inventoryType = INV_TYPES.CHEST
tinsert(self._classFiltersTemp, self._classFilter1)
self._classFilter2.classID = LE_ITEM_CLASS_ARMOR
self._classFilter2.subClassID = self._subClass
self._classFilter2.inventoryType = INV_TYPES.ROBE
tinsert(self._classFiltersTemp, self._classFilter2)
elseif self._invType == INV_TYPES.NECK or self._invType == INV_TYPES.FINGER or self._invType == INV_TYPES.TRINKET or self._invType == INV_TYPES.HOLDABLE or self._invType == INV_TYPES.BODY then
self._classFilter1.classID = LE_ITEM_CLASS_ARMOR
self._classFilter1.subClassID = LE_ITEM_ARMOR_GENERIC
self._classFilter1.inventoryType = self._invType
tinsert(self._classFiltersTemp, self._classFilter1)
elseif self._invType == INV_TYPES.CLOAK then
self._classFilter1.classID = LE_ITEM_CLASS_ARMOR
self._classFilter1.subClassID = LE_ITEM_ARMOR_CLOTH
self._classFilter1.inventoryType = self._invType
tinsert(self._classFiltersTemp, self._classFilter1)
elseif self._class then
self._classFilter1.classID = self._class
self._classFilter1.subClassID = self._subClass
self._classFilter1.inventoryType = self._invType
tinsert(self._classFiltersTemp, self._classFilter1)
end
-- build the query
local minLevel = self._minLevel ~= -math.huge and self._minLevel or nil
local maxLevel = self._maxLevel ~= math.huge and self._maxLevel or nil
if TSM.IsWowClassic() then
if self._specifiedPage == "LAST" then
self._page = max(ceil(select(2, GetNumAuctionItems("list")) / NUM_AUCTION_ITEMS_PER_PAGE) - 1, 0)
elseif self._specifiedPage == "FIRST" then
self._page = 0
elseif self._specifiedPage then
self._page = self._specifiedPage
end
local minQuality = self._minQuality == -math.huge and 0 or self._minQuality
return AuctionHouseWrapper.QueryAuctionItems(self._str, minLevel, maxLevel, self._page, self._usable, minQuality, self._getAll, self._exact, self._classFiltersTemp)
else
wipe(self._filtersTemp)
if self._uncollected then
tinsert(self._filtersTemp, Enum.AuctionHouseFilter.UncollectedOnly)
end
if self._usable then
tinsert(self._filtersTemp, Enum.AuctionHouseFilter.UsableOnly)
end
if self._upgrades then
tinsert(self._filtersTemp, Enum.AuctionHouseFilter.UpgradesOnly)
end
if self._exact then
tinsert(self._filtersTemp, Enum.AuctionHouseFilter.ExactMatch)
end
local minQuality = self._minQuality == -math.huge and 0 or self._minQuality
for i = minQuality + Enum.AuctionHouseFilter.PoorQuality, min(self._maxQuality + Enum.AuctionHouseFilter.PoorQuality, Enum.AuctionHouseFilter.ArtifactQuality) do
tinsert(self._filtersTemp, i)
end
wipe(self._queryTemp)
self._queryTemp.searchString = self._str
self._queryTemp.minLevel = minLevel
self._queryTemp.maxLevel = maxLevel
self._queryTemp.sorts = DEFAULT_SORTS
self._queryTemp.filters = self._filtersTemp
self._queryTemp.itemClassFilters = self._classFiltersTemp
return AuctionHouseWrapper.SendBrowseQuery(self._queryTemp)
end
end
function AuctionQuery._IsFiltered(self, row, isSubRow, itemKey)
local baseItemString = row:GetBaseItemString()
local itemString = row:GetItemString()
assert(baseItemString)
local name, quality, itemLevel, maxItemLevel = row:GetItemInfo(itemKey)
local _, itemBuyout, minItemBuyout = row:GetBuyouts(itemKey)
if row:IsSubRow() and itemBuyout == 0 then
_, itemBuyout = row:GetBidInfo()
end
if next(self._items) then
if not self._items[baseItemString] then
return true
end
if isSubRow and itemString and self._items[itemString] ~= ITEM_SPECIFIC and self._items[baseItemString] ~= ITEM_SPECIFIC then
return true
elseif not isSubRow and itemString and not self._items[itemString] then
return true
end
end
if self._str ~= "" and name then
name = strlower(name)
if not strmatch(name, self._strMatch) or (self._exact and name ~= self._strLower) then
return true
end
end
if self._minLevel ~= -math.huge or self._maxLevel ~= math.huge then
local minLevel = TSM.IsShadowlands() and ItemString.IsPet(baseItemString) and (itemLevel or maxItemLevel) or ItemInfo.GetMinLevel(baseItemString)
if minLevel < self._minLevel or minLevel > self._maxLevel then
return true
end
end
if itemLevel and (itemLevel < self._minItemLevel or itemLevel > self._maxItemLevel) then
return true
end
if maxItemLevel and maxItemLevel < self._minItemLevel then
return true
end
if quality and (quality < self._minQuality or quality > self._maxQuality) then
return true
end
if self._class and ItemInfo.GetClassId(baseItemString) ~= self._class then
return true
end
if self._subClass and ItemInfo.GetSubClassId(baseItemString) ~= self._subClass then
return true
end
if self._invType and ItemInfo.GetInvSlotId(baseItemString) ~= self._invType then
return true
end
if self._unlearned and CanIMogIt:PlayerKnowsTransmog(ItemInfo.GetLink(baseItemString)) then
return true
end
if self._canLearn and not CanIMogIt:CharacterCanLearnTransmog(ItemInfo.GetLink(baseItemString)) then
return true
end
if itemBuyout and (itemBuyout < self._minPrice or itemBuyout > self._maxPrice) then
return true
end
if minItemBuyout and minItemBuyout > self._maxPrice then
return true
end
for func in pairs(self._customFilters) do
if func(self, row, isSubRow, itemKey) then
return true
end
end
return false
end
function AuctionQuery._BrowseIsDone(self, isRetry)
if TSM.IsWowClassic() then
local numAuctions, totalAuctions = GetNumAuctionItems("list")
if totalAuctions <= NUM_AUCTION_ITEMS_PER_PAGE and numAuctions ~= totalAuctions then
-- there are cases where we get (0, 1) from the API - no idea why so just assume we're not done
return false
end
local numPages = ceil(totalAuctions / NUM_AUCTION_ITEMS_PER_PAGE)
if self._getAll then
return true
end
if self._specifiedPage then
if isRetry then
return false
end
-- check if we're on the right page
local specifiedPage = (self._specifiedPage == "FIRST" and 0) or (self._specifiedPage == "LAST" and numPages - 1) or self._specifiedPage
return self._page == specifiedPage
elseif self._isBrowseDoneFunc and self._isBrowseDoneFunc(self) then
return true
else
return self._page >= numPages
end
else
if self._isBrowseDoneFunc and self._isBrowseDoneFunc(self) then
return true
end
return C_AuctionHouse.HasFullBrowseResults()
end
end
function AuctionQuery._BrowseIsPageValid(self)
if TSM.IsWowClassic() then
if self._specifiedPage then
return self:_BrowseIsDone()
else
return true
end
else
return true
end
end
function AuctionQuery._BrowseRequestMore(self, isRetry)
if TSM.IsWowClassic() then
assert(not self._getAll)
if self._specifiedPage then
return self:_SendWowQuery()
end
if not isRetry then
self._page = self._page + 1
end
return self:_SendWowQuery()
else
return AuctionHouseWrapper.RequestMoreBrowseResults()
end
end
function AuctionQuery._OnSubRowRemoved(self, row)
local baseItemString = row:GetBaseItemString()
assert(row == self._browseResults[baseItemString])
if row:GetNumSubRows() == 0 then
self._browseResults[baseItemString] = nil
row:Release()
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ItemIteratorHelper(items, index)
while true do
local itemString, itemType = next(items, index)
if not itemString then
return
elseif itemType == ITEM_SPECIFIC then
return itemString
end
index = itemString
end
end

View File

@@ -0,0 +1,134 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local QueryUtil = TSM.Init("Service.AuctionScanClasses.QueryUtil")
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local Threading = TSM.Include("Service.Threading")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Query = TSM.Include("Service.AuctionScanClasses.Query")
local private = {
itemListSortValue = {},
}
local MAX_ITEM_INFO_RETRIES = 30
-- ============================================================================
-- Module Functions
-- ============================================================================
function QueryUtil.GenerateThreaded(itemList, callback, context)
-- get all the item info into the game's cache
for _ = 1, MAX_ITEM_INFO_RETRIES do
local isMissingItemInfo = false
for _, itemString in ipairs(itemList) do
if not private.HasInfo(itemString) then
isMissingItemInfo = true
end
Threading.Yield()
end
if not isMissingItemInfo then
break
end
Threading.Sleep(0.1)
end
-- remove items we're missing info for
for i = #itemList, 1, -1 do
if not private.HasInfo(itemList[i]) then
Log.Err("Missing item info for %s", itemList[i])
tremove(itemList, i)
end
Threading.Yield()
end
if #itemList == 0 then
return
end
-- add all the items
if TSM.IsWowClassic() then
for _, itemString in ipairs(itemList) do
private.GenerateQuery(callback, context, itemString, private.GetItemQueryInfo(itemString))
end
else
-- sort the item list so all base items are grouped together but keep relative ordering between base items the same
wipe(private.itemListSortValue)
for i, itemString in ipairs(itemList) do
local baseItemString = ItemString.GetBaseFast(itemString)
private.itemListSortValue[baseItemString] = private.itemListSortValue[baseItemString] or i
private.itemListSortValue[itemString] = private.itemListSortValue[baseItemString]
end
sort(itemList, private.ItemListSortHelper)
local currentBaseItemString = nil
local currentItems = TempTable.Acquire()
for _, itemString in ipairs(itemList) do
local baseItemString = ItemString.GetBaseFast(itemString)
assert(baseItemString)
if baseItemString == currentBaseItemString then
-- same base item
tinsert(currentItems, itemString)
else
-- new base item
if currentBaseItemString then
private.GenerateQuery(callback, context, currentItems, ItemInfo.GetName(currentBaseItemString))
wipe(currentItems)
end
currentBaseItemString = baseItemString
tinsert(currentItems, itemString)
end
end
if currentBaseItemString then
private.GenerateQuery(callback, context, currentItems, ItemInfo.GetName(currentBaseItemString))
wipe(currentItems)
end
TempTable.Release(currentItems)
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetItemQueryInfo(itemString)
local name = ItemInfo.GetName(itemString)
local level = ItemInfo.GetMinLevel(itemString) or 0
local quality = ItemInfo.GetQuality(itemString)
local classId = ItemInfo.GetClassId(itemString) or 0
local subClassId = ItemInfo.GetSubClassId(itemString) or 0
-- Ignoring level because level can now vary
if itemString == ItemString.GetBase(itemString) and (classId == LE_ITEM_CLASS_WEAPON or classId == LE_ITEM_CLASS_ARMOR or (classId == LE_ITEM_CLASS_GEM and subClassId == LE_ITEM_GEM_ARTIFACTRELIC)) then
level = nil
end
return name, level, level, quality, classId, subClassId
end
function private.HasInfo(itemString)
return ItemInfo.GetName(itemString) and ItemInfo.GetQuality(itemString) and ItemInfo.GetMinLevel(itemString)
end
function private.GenerateQuery(callback, context, items, name, minLevel, maxLevel, quality, class, subClass)
local query = Query.Get()
:SetStr(name, false)
:SetQualityRange(quality, quality)
:SetLevelRange(minLevel, maxLevel)
:SetClass(class, subClass)
:SetItems(items)
callback(query, context)
end
function private.ItemListSortHelper(a, b)
local aSortValue = private.itemListSortValue[a]
local bSortValue = private.itemListSortValue[b]
if aSortValue ~= bSortValue then
return aSortValue < bSortValue
end
return a < b
end

View File

@@ -0,0 +1,671 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ResultRow = TSM.Init("Service.AuctionScanClasses.ResultRow")
local ItemString = TSM.Include("Util.ItemString")
local ObjectPool = TSM.Include("Util.ObjectPool")
local TempTable = TSM.Include("Util.TempTable")
local Table = TSM.Include("Util.Table")
local ItemInfo = TSM.Include("Service.ItemInfo")
local LibTSMClass = TSM.Include("LibTSMClass")
local Util = TSM.Include("Service.AuctionScanClasses.Util")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local ResultSubRow = TSM.Include("Service.AuctionScanClasses.ResultSubRow")
local ResultRowWrapper = LibTSMClass.DefineClass("ResultRowWrapper")
local private = {
objectPool = ObjectPool.New("AUCTION_SCAN_RESULT_ROW", ResultRowWrapper),
}
local SUB_ROW_SEARCH_INDEX_MULTIPLIER = 1000000
-- ============================================================================
-- Module Functions
-- ============================================================================
function ResultRow.Get(query, itemKey, minPrice, totalQuantity)
local row = private.objectPool:Get()
row:_Acquire(query, itemKey, minPrice, totalQuantity)
return row
end
-- ============================================================================
-- ResultRowWrapper - Meta Class Methods
-- ============================================================================
function ResultRowWrapper.__init(self)
self._query = nil
self._items = {}
self._baseItemString = nil
self._canHaveNonBaseItemString = nil
self._minPrice = nil
self._hasItemInfo = nil
self._isCommodity = nil
self._notFiltered = false
self._searchIndex = nil
self._subRows = {}
self._minBrowseId = nil
end
function ResultRowWrapper._Acquire(self, query, item, minPrice, totalQuantity)
self._query = query
if TSM.IsWowClassic() then
assert(not minPrice and not totalQuantity)
tinsert(self._items, item)
self._baseItemString = ItemString.GetBase(item)
else
item._minPrice = minPrice
item._totalQuantity = totalQuantity
tinsert(self._items, item)
self._baseItemString = ItemString.GetBaseFromItemKey(item)
end
self._canHaveNonBaseItemString = nil
self._minPrice = nil
end
-- ============================================================================
-- ResultRowWrapper - Public Class Methods
-- ============================================================================
function ResultRowWrapper.Merge(self, item, minPrice, totalQuantity)
-- check if we already have this item
for i = 1, #self._items do
if item == self._items[i] then
return
end
if type(item) == "table" then
local isEqual = true
for k in pairs(item) do
if item[k] ~= self._items[i][k] then
isEqual = false
break
end
end
if isEqual then
return
end
end
end
self._hasItemInfo = nil
if TSM.IsWowClassic() then
assert(not minPrice and not totalQuantity)
assert(self._baseItemString == ItemString.GetBase(item))
tinsert(self._items, item)
self._notFiltered = false
else
assert(self._baseItemString == ItemString.GetBaseFromItemKey(item))
item._minPrice = minPrice
item._totalQuantity = totalQuantity
tinsert(self._items, item)
self._notFiltered = false
end
self._canHaveNonBaseItemString = nil
end
function ResultRowWrapper.Release(self)
wipe(self._items)
self._baseItemString = nil
self._canHaveNonBaseItemString = nil
self._minPrice = nil
self._hasItemInfo = nil
self._isCommodity = nil
self._notFiltered = false
self._searchIndex = nil
self._minBrowseId = nil
for _, subRow in pairs(self._subRows) do
subRow:Release()
end
wipe(self._subRows)
private.objectPool:Recycle(self)
end
function ResultRowWrapper.IsSubRow(self)
return false
end
function ResultRowWrapper.PopulateBrowseData(self)
assert(self._baseItemString)
if self._hasItemInfo then
-- already have our item info
return true
elseif not Util.HasItemInfo(self._baseItemString) then
-- don't have item info yet
return false
end
if not TSM.IsWowClassic() then
-- cache the commodity status since it's referenced a ton
if self._isCommodity == nil then
self._isCommodity = ItemInfo.IsCommodity(self._baseItemString)
assert(self._isCommodity ~= nil)
end
end
-- check if we have info for all the items and try to fetch it if not
local missingInfo = false
for _, item in ipairs(self._items) do
if TSM.IsWowClassic() then
if not Util.HasItemInfo(ItemString.Get(item)) then
missingInfo = true
end
else
if not item._itemKeyInfo then
item._itemKeyInfo = C_AuctionHouse.GetItemKeyInfo(item, true)
if not item._itemKeyInfo then
missingInfo = true
end
end
end
end
if missingInfo then
return false
end
self._hasItemInfo = true
return true
end
function ResultRowWrapper.IsFiltered(self, query)
assert(#self._items > 0)
if self._notFiltered then
return false
end
-- check if the whole row is filtered
if query:_IsFiltered(self, false) then
return true
end
-- filter our items
for i = #self._items, 1, -1 do
if query:_IsFiltered(self, false, self._items[i]) then
tremove(self._items, i)
end
end
self._canHaveNonBaseItemString = nil
self._minPrice = nil
if #self._items == 0 then
-- no more items, so the entire row is filtered
return true
end
-- not filtered (cache this result)
self._notFiltered = true
return false
end
function ResultRowWrapper.SearchReset(self)
assert(not TSM.IsWowClassic())
assert(#self._items > 0)
self._searchIndex = 1
end
function ResultRowWrapper.SearchNext(self)
assert(not TSM.IsWowClassic())
assert(self._searchIndex)
if self._searchIndex == #self._items then
self._searchIndex = nil
return false
end
self._searchIndex = self._searchIndex + 1
return true
end
function ResultRowWrapper.SearchIsReady(self)
assert(not TSM.IsWowClassic())
assert(self._searchIndex)
-- the client needs to have the item key info cached before we can run the search
return C_AuctionHouse.GetItemKeyInfo(self._items[self._searchIndex], true) and true or false
end
function ResultRowWrapper.SearchSend(self)
assert(not TSM.IsWowClassic())
assert(self._searchIndex)
local itemKey = self._items[self._searchIndex]
-- send a sell query if we don't have browse results for the itemKey
-- for some reason sell queries don't work for commodities or pets
local isSellQuery = not self._isCommodity and not ItemString.IsPet(self._baseItemString) and not itemKey._totalQuantity
return AuctionHouseWrapper.SendSearchQuery(itemKey, isSellQuery)
end
function ResultRowWrapper.HasCachedSearchData(self)
local itemKey = self._items[self._searchIndex]
if self._isCommodity then
return C_AuctionHouse.HasFullCommoditySearchResults(itemKey.itemID)
else
return C_AuctionHouse.HasFullItemSearchResults(itemKey)
end
end
function ResultRowWrapper.SearchCheckStatus(self)
assert(not TSM.IsWowClassic())
assert(self._searchIndex)
local itemKey = self._items[self._searchIndex]
-- check if we have the full results
local hasFullResults = nil
if self._isCommodity then
hasFullResults = C_AuctionHouse.HasFullCommoditySearchResults(itemKey.itemID)
else
hasFullResults = C_AuctionHouse.HasFullItemSearchResults(itemKey)
end
if hasFullResults then
return true
end
-- request more results
if self._isCommodity then
return false, AuctionHouseWrapper.RequestMoreCommoditySearchResults(itemKey.itemID)
else
return false, AuctionHouseWrapper.RequestMoreItemSearchResults(itemKey)
end
end
function ResultRowWrapper.PopulateSubRows(self, browseId, index, itemLink)
if TSM.IsWowClassic() then
-- remove any prior results with a different browseId
assert(index and not self._searchIndex)
local subRow = ResultSubRow.Get(self)
subRow:_SetRawData(index, browseId, itemLink)
local _, hashNoSeller = subRow:GetHashes()
if self._minBrowseId and self._minBrowseId ~= browseId then
-- check if this subRow already exists with a prior browseId
for i, existingSubRow in ipairs(self._subRows) do
local _, existingHashNoSeller = existingSubRow:GetHashes()
local _, _, existingBrowseId = existingSubRow:GetListingInfo()
if hashNoSeller == existingHashNoSeller and browseId ~= existingBrowseId then
-- replace the existing subRow
existingSubRow:Release()
self._subRows[i] = subRow
return
end
end
end
tinsert(self._subRows, subRow)
else
assert(self._searchIndex and not index)
local subRowOffset = self._searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER
local itemKey = self._items[self._searchIndex]
local numAuctions = nil
if self:IsCommodity() then
numAuctions = C_AuctionHouse.GetNumCommoditySearchResults(itemKey.itemID)
else
numAuctions = C_AuctionHouse.GetNumItemSearchResults(itemKey)
end
if itemKey._numAuctions and numAuctions ~= itemKey._numAuctions then
-- the results changed so clear out our existing data
for i = itemKey._numAuctions, 1, -1 do
if i > numAuctions then
self._subRows[subRowOffset + i]:Release()
self._subRows[subRowOffset + i] = nil
else
self._subRows[subRowOffset + i]:_SetRawData(nil)
end
end
end
itemKey._numAuctions = numAuctions
for i = 1, numAuctions do
self._subRows[subRowOffset + i] = self._subRows[subRowOffset + i] or ResultSubRow.Get(self)
local subRow = self._subRows[subRowOffset + i]
if not subRow:HasRawData() or not subRow:HasOwners() then
local result = nil
if self:IsCommodity() then
result = C_AuctionHouse.GetCommoditySearchResultInfo(itemKey.itemID, i)
else
result = C_AuctionHouse.GetItemSearchResultInfo(itemKey, i)
end
subRow:_SetRawData(result, browseId)
end
end
end
self._minBrowseId = min(self._minBrowseId or math.huge, browseId)
end
function ResultRowWrapper.FilterSubRows(self, query)
local subRowOffset = TSM.IsWowClassic() and 0 or (self._searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER)
if TSM.IsWowClassic() then
for i = #self._subRows, 1, -1 do
if query:_IsFiltered(self._subRows[i], true) then
self:_RemoveSubRowByIndex(i)
end
end
else
local itemKey = self._items[self._searchIndex]
for j = itemKey._numAuctions, 1, -1 do
local subRow = self._subRows[subRowOffset + j]
if query:_IsFiltered(subRow, true) then
self:_RemoveSubRowByIndex(j)
end
end
end
-- merge subRows with identical hashes
local numSubRows = nil
local hashIndexLookup = TempTable.Acquire()
local index = 1
while true do
numSubRows = TSM.IsWowClassic() and #self._subRows or self._items[self._searchIndex]._numAuctions
if index > numSubRows then
break
end
local subRow = self._subRows[subRowOffset + index]
local hash = subRow:GetHashes()
local prevIndex = hashIndexLookup[hash]
if prevIndex then
-- there was a previous subRow with the same hash
self._subRows[subRowOffset + prevIndex]:Merge(subRow)
-- remove this subRow
self:_RemoveSubRowByIndex(index)
else
hashIndexLookup[hash] = index
index = index + 1
end
end
TempTable.Release(hashIndexLookup)
return numSubRows == 0
end
function ResultRowWrapper.GetNumSubRows(self)
if TSM.IsWowClassic() then
return #self._subRows
else
local result = 0
for _, itemKey in ipairs(self._items) do
result = result + (itemKey._numAuctions or 0)
end
return result
end
end
function ResultRowWrapper.SubRowIterator(self, searchOnly)
if TSM.IsWowClassic() then
return ipairs(self._subRows)
else
if searchOnly then
local result = TempTable.Acquire()
assert(self._searchIndex)
for i = 1, self._items[self._searchIndex]._numAuctions do
local subRow = self._subRows[self._searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER + i]
assert(subRow)
tinsert(result, subRow)
end
return TempTable.Iterator(result)
else
return private.SubRowIteratorHelper, self, SUB_ROW_SEARCH_INDEX_MULTIPLIER
end
end
end
function ResultRowWrapper.IsCommodity(self)
assert(self._isCommodity ~= nil)
return self._isCommodity
end
function ResultRowWrapper.HasItemInfo(self)
return self._hasItemInfo
end
function ResultRowWrapper.GetBaseItemString(self)
return self._baseItemString
end
function ResultRowWrapper.GetItemString(self)
if TSM.IsWowClassic() or not self._hasItemInfo or self._canHaveNonBaseItemString then
return nil
end
if self._canHaveNonBaseItemString == nil then
for _, itemKey in ipairs(self._items) do
if ItemInfo.CanHaveVariations(self._baseItemString) or itemKey.battlePetSpeciesID ~= 0 or itemKey.itemSuffix ~= 0 or itemKey.itemLevel ~= 0 then
-- this item can have variations, so we don't know its itemString
self._canHaveNonBaseItemString = true
return nil
end
end
self._canHaveNonBaseItemString = false
end
return self._baseItemString
end
function ResultRowWrapper.GetItemInfo(self, itemKey)
if TSM.IsWowClassic() or not self._hasItemInfo then
return nil, nil, nil, nil
end
itemKey = itemKey or (#self._items == 1 and self._items[1] or nil)
assert(not itemKey or itemKey._itemKeyInfo)
local baseItemString = self:GetBaseItemString()
local itemString = self:GetItemString()
local itemName, quality, itemLevel, maxItemLevel = nil, nil, nil, nil
if itemString then
-- this item can't have variations, so we can know the name / level / quality
itemName = ItemInfo.GetName(baseItemString)
itemLevel = ItemInfo.GetItemLevel(baseItemString)
quality = ItemInfo.GetQuality(baseItemString)
assert(itemName and itemLevel and quality)
else
if itemKey and not itemKey._totalQuantity then
-- if we didn't do a browse, then don't use this itemKey
itemKey = nil
end
if itemKey then
-- grab the name from the itemKeyInfo
itemName = itemKey._itemKeyInfo.itemName
assert(itemName)
end
local hasSingleAuction = itemKey and itemKey._totalQuantity == 1
if hasSingleAuction then
-- grab the quality from the itemKeyInfo since there's only one listing
quality = itemKey._itemKeyInfo.quality
assert(quality)
end
if not ItemString.IsPet(self._baseItemString) then
-- for non-pets, we can maybe grab the itemLevel from the itemKey
if itemKey then
itemLevel = itemKey.itemLevel ~= 0 and itemKey.itemLevel or nil
else
-- only use the itemLevel from the itemKeys if they are all the same
local itemKeyItemLevel = self._items[1].itemLevel
for i = 2, #self._items do
if self._items[i].itemLevel ~= itemKeyItemLevel then
itemKeyItemLevel = nil
break
end
end
itemLevel = (itemKeyItemLevel or 0) ~= 0 and itemKeyItemLevel or nil
end
elseif itemKey and itemKey._itemKeyInfo.battlePetLink then
if hasSingleAuction then
-- grab the itemLevel from the link since there's only one listing
itemLevel = ItemInfo.GetItemLevel(itemKey._itemKeyInfo.battlePetLink)
assert(itemLevel)
else
-- grab the maxItemLevel from the link
maxItemLevel = ItemInfo.GetItemLevel(itemKey._itemKeyInfo.battlePetLink)
assert(maxItemLevel)
end
end
end
return itemName, quality, itemLevel, maxItemLevel
end
function ResultRowWrapper.GetBuyouts(self, resultItemKey)
if TSM.IsWowClassic() then
return nil, nil, nil
end
assert(#self._items > 0)
if resultItemKey then
return nil, nil, resultItemKey._minPrice
else
if self._minPrice == nil then
for _, itemKey in ipairs(self._items) do
if not itemKey._minPrice then
self._minPrice = -1
return nil, nil, nil
end
self._minPrice = min(self._minPrice or math.huge, itemKey._minPrice)
end
elseif self._minPrice == -1 then
return nil, nil, nil
end
return nil, nil, self._minPrice
end
end
function ResultRowWrapper.GetQuantities(self)
local totalQuantity = 0
if TSM.IsWowClassic() then
for _, subRow in ipairs(self._subRows) do
local quantity, numAuctions = subRow:GetQuantities()
totalQuantity = totalQuantity + quantity * numAuctions
end
else
for _, itemKey in ipairs(self._items) do
if not itemKey._totalQuantity then
return
end
totalQuantity = totalQuantity + itemKey._totalQuantity
end
end
return totalQuantity, 1
end
function ResultRowWrapper.GetMaxQuantities(self)
assert(self:IsCommodity())
local totalQuantity = 0
for _, subRow in self:SubRowIterator() do
local _, numOwnerItems = subRow:GetOwnerInfo()
local quantityAvailable = subRow:GetQuantities() - numOwnerItems
totalQuantity = totalQuantity + quantityAvailable
end
return totalQuantity
end
function ResultRowWrapper.RemoveSubRow(self, subRow)
local index = Table.KeyByValue(self._subRows, subRow)
if TSM.IsWowClassic() then
self:_RemoveSubRowByIndex(index)
else
local searchIndex = floor(index / SUB_ROW_SEARCH_INDEX_MULTIPLIER)
index = index % SUB_ROW_SEARCH_INDEX_MULTIPLIER
assert(self._subRows[searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER + index] == subRow)
local prevSearchIndex = self._searchIndex
self._searchIndex = searchIndex
self:_RemoveSubRowByIndex(index)
self._searchIndex = prevSearchIndex
end
self._query:_OnSubRowRemoved(self)
end
function ResultRowWrapper.WipeSearchResults(self)
wipe(self._subRows)
if not TSM.IsWowClassic() then
for _, itemKey in ipairs(self._items) do
itemKey._numAuctions = nil
end
end
end
function ResultRowWrapper.GetQuery(self)
return self._query
end
function ResultRowWrapper.DecrementQuantity(self, amount)
assert(self:IsCommodity() and not TSM.IsWowClassic() and #self._items == 1)
local index = 1
while amount > 0 do
local subRow = self._subRows[index + SUB_ROW_SEARCH_INDEX_MULTIPLIER]
assert(subRow)
local _, numOwnerItems = subRow:GetOwnerInfo()
local quantityAvailable = subRow:GetQuantities() - numOwnerItems
if quantityAvailable > 0 then
local usedQuantity = min(quantityAvailable, amount)
local prevItemBuyout = floor(subRow._buyout / subRow._quantity)
amount = amount - usedQuantity
subRow._quantity = subRow._quantity - usedQuantity
subRow._buyout = prevItemBuyout * subRow._quantity
subRow._minBid = subRow._buyout
if numOwnerItems == 0 and subRow._quantity == 0 then
self:RemoveSubRow(subRow)
else
index = index + 1
end
else
index = index + 1
end
end
end
function ResultRowWrapper.GetMinBrowseId(self)
return self._minBrowseId
end
-- ============================================================================
-- ResultRowWrapper - Private Class Methods
-- ============================================================================
function ResultRowWrapper._RemoveSubRowByIndex(self, index)
if TSM.IsWowClassic() then
self._subRows[index]:Release()
tremove(self._subRows, index)
else
local subRowOffset = self._searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER
local itemKey = self._items[self._searchIndex]
self._subRows[subRowOffset + index]:Release()
self._subRows[subRowOffset + index] = nil
-- shift the other subRows for this item down
for i = index, itemKey._numAuctions - 1 do
self._subRows[subRowOffset + i] = self._subRows[subRowOffset + i + 1]
end
self._subRows[subRowOffset + itemKey._numAuctions] = nil
itemKey._numAuctions = itemKey._numAuctions - 1
end
end
function ResultRowWrapper._GetSearchProgress(self)
assert(not TSM.IsWowClassic())
if #self._items == 0 then
return 0
end
local numSearched = 0
for _, itemKey in ipairs(self._items) do
if itemKey._numAuctions then
numSearched = numSearched + 1
end
end
return numSearched / #self._items
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.SubRowIteratorHelper(row, index)
local searchIndex = floor(index / SUB_ROW_SEARCH_INDEX_MULTIPLIER)
local subRowIndex = index % SUB_ROW_SEARCH_INDEX_MULTIPLIER
while true do
local itemKey = row._items[searchIndex]
if not itemKey then
return
end
if subRowIndex >= (itemKey._numAuctions or 0) then
searchIndex = searchIndex + 1
subRowIndex = 0
else
subRowIndex = subRowIndex + 1
index = searchIndex * SUB_ROW_SEARCH_INDEX_MULTIPLIER + subRowIndex
return index, row._subRows[index]
end
end
end

View File

@@ -0,0 +1,364 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ResultSubRow = TSM.Init("Service.AuctionScanClasses.ResultSubRow")
local ItemString = TSM.Include("Util.ItemString")
local ObjectPool = TSM.Include("Util.ObjectPool")
local Math = TSM.Include("Util.Math")
local ItemInfo = TSM.Include("Service.ItemInfo")
local LibTSMClass = TSM.Include("LibTSMClass")
local Util = TSM.Include("Service.AuctionScanClasses.Util")
local ResultSubRowWrapper = LibTSMClass.DefineClass("ResultSubRowWrapper")
local private = {
objectPool = ObjectPool.New("AUCTION_SCAN_RESULT_SUB_ROW", ResultSubRowWrapper),
ownersTemp = {},
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function ResultSubRow.Get(resultRow)
local subRow = private.objectPool:Get()
subRow:_Acquire(resultRow)
return subRow
end
-- ============================================================================
-- ResultSubRowWrapper - Meta Class Methods
-- ============================================================================
function ResultSubRowWrapper.__init(self)
self._resultRow = nil
self._itemLink = nil
self._buyout = nil
self._minBid = nil
self._currentBid = nil
self._minIncrement = nil
self._isHighBidder = nil
self._quantity = nil
self._timeLeft = nil
self._ownerStr = nil
self._hasOwners = false
self._numOwnerItems = nil
self._auctionId = nil
self._hash = nil
self._hashNoSeller = nil
self._browseId = nil
self._numAuctions = 1
end
function ResultSubRowWrapper._Acquire(self, resultRow)
self._resultRow = resultRow
end
-- ============================================================================
-- ResultSubRowWrapper - Public Class Methods
-- ============================================================================
function ResultSubRowWrapper.Merge(self, other)
if TSM.IsWowClassic() then
self._numAuctions = self._numAuctions + other._numAuctions
else
if self:IsCommodity() then
self._quantity = self._quantity + other._quantity
self._numOwnerItems = self._numOwnerItems + other._numOwnerItems
else
self._numAuctions = self._numAuctions + other._numAuctions
end
end
end
function ResultSubRowWrapper.Release(self)
self._resultRow = nil
self._numAuctions = 1
self:_SetRawData(nil)
private.objectPool:Recycle(self)
end
function ResultSubRowWrapper.IsSubRow(self)
return true
end
function ResultSubRowWrapper.HasRawData(self)
return self._timeLeft and true or false
end
function ResultSubRowWrapper.HasOwners(self)
return self._hasOwners
end
function ResultSubRowWrapper.HasItemString(self)
assert(self:HasRawData())
local itemString = ItemString.Get(self._itemLink)
if not Util.HasItemInfo(itemString) then
return false
end
return true
end
function ResultSubRowWrapper.IsCommodity(self)
return self._resultRow:IsCommodity()
end
function ResultSubRowWrapper.GetResultRow(self)
return self._resultRow
end
function ResultSubRowWrapper.GetBaseItemString(self)
return self._resultRow:GetBaseItemString()
end
function ResultSubRowWrapper.GetItemString(self)
assert(self:HasRawData())
local itemString = ItemString.Get(self._itemLink)
return itemString or self._resultRow:GetItemString()
end
function ResultSubRowWrapper.GetItemInfo(self)
assert(self:HasItemString())
local itemString = ItemString.Get(self._itemLink)
local itemName = ItemInfo.GetName(itemString)
local quality = ItemInfo.GetQuality(itemString)
local itemLevel = ItemInfo.GetItemLevel(itemString)
assert(itemName and quality and itemLevel)
return itemName, quality, itemLevel, nil
end
function ResultSubRowWrapper.GetBuyouts(self)
assert(self:HasRawData())
return self._buyout, floor(self._buyout / self._quantity), nil
end
function ResultSubRowWrapper.GetBidInfo(self)
assert(self:HasRawData())
local itemMinBid = Math.Floor(self._minBid / self._quantity, TSM.IsWowClassic() and 1 or COPPER_PER_SILVER)
return self._minBid, itemMinBid, self._currentBid, self._isHighBidder, self._minIncrement
end
function ResultSubRowWrapper.GetRequiredBid(self)
local requiredBid = nil
if TSM.IsWowClassic() then
requiredBid = self._currentBid == 0 and self._minBid or (self._currentBid + self._minIncrement)
else
requiredBid = self._minBid
end
return requiredBid
end
function ResultSubRowWrapper.GetDisplayedBids(self)
local displayedBid = self._currentBid == 0 and self._minBid or self._currentBid
local itemDisplayedBid = Math.Floor(displayedBid / self._quantity, TSM.IsWowClassic() and 1 or COPPER_PER_SILVER)
return displayedBid, itemDisplayedBid
end
function ResultSubRowWrapper.GetLinks(self)
assert(self:HasRawData())
local rawLink = self._itemLink
local itemLink = ItemInfo.GeneralizeLink(rawLink)
return itemLink, rawLink
end
function ResultSubRowWrapper.GetListingInfo(self)
assert(self:HasRawData())
return self._timeLeft, self._auctionId, self._browseId
end
function ResultSubRowWrapper.GetQuantities(self)
assert(self:HasRawData())
return self._quantity, self._numAuctions
end
function ResultSubRowWrapper.GetOwnerInfo(self)
assert(self:HasRawData())
return self._ownerStr, self._numOwnerItems
end
function ResultSubRowWrapper.GetHashes(self)
if not self._hash then
assert(self:HasRawData())
if TSM.IsWowClassic() then
self._hash = strjoin("~", tostringall(self._itemLink, self._minBid, self._minIncrement, self._buyout, self._currentBid, self._ownerStr, self._timeLeft, self._quantity, self._isHighBidder))
self._hashNoSeller = strjoin("~", tostringall(self._itemLink, self._minBid, self._minIncrement, self._buyout, self._currentBid, self._timeLeft, self._quantity, self._isHighBidder))
else
local baseItemString = self:GetBaseItemString()
local itemMinBid = Math.Floor(self._minBid / self._quantity, COPPER_PER_SILVER)
local itemBuyout = floor(self._buyout / self._quantity)
local itemKeyId, itemKeySpeciesId = nil, nil
if ItemString.IsPet(baseItemString) then
itemKeyId = ItemString.ToId(ItemString.GetPetCage())
itemKeySpeciesId = ItemString.ToId(baseItemString)
elseif ItemString.IsItem(baseItemString) then
itemKeyId = ItemString.ToId(baseItemString)
itemKeySpeciesId = 0
else
error("Invalid baseItemString: "..tostring(baseItemString))
end
if self:IsCommodity() then
self._hash = strjoin("~", tostringall(itemKeyId, itemBuyout, self._auctionId, self._ownerStr))
self._hashNoSeller = strjoin("~", tostringall(itemKeyId, itemBuyout, self._auctionId))
else
self._hash = strjoin("~", tostringall(itemKeyId, itemKeySpeciesId, self._itemLink, itemMinBid, itemBuyout, self._currentBid, self._quantity, self._isHighBidder, self._ownerStr, self._auctionId))
self._hashNoSeller = strjoin("~", tostringall(itemKeyId, itemKeySpeciesId, self._itemLink, itemMinBid, itemBuyout, self._currentBid, self._quantity, self._isHighBidder, self._auctionId))
end
end
end
return self._hash, self._hashNoSeller
end
function ResultSubRowWrapper.EqualsIndex(self, index, noSeller)
assert(TSM.IsWowClassic())
local _, _, stackSize, _, _, _, _, minBid, minIncrement, buyout, bid, isHighBidder, _, seller, sellerFull = GetAuctionItemInfo("list", index)
seller = Util.FixSellerName(seller, sellerFull) or "?"
-- this is to get around a bug in Blizzard's code where the minIncrement value will be inconsistent for auctions where the player is the highest bidder
minIncrement = isHighBidder and 0 or minIncrement
if minBid ~= self._minBid or minIncrement ~= self._minIncrement or buyout ~= self._buyout or bid ~= self._currentBid or stackSize == self._quantity and isHighBidder ~= self._isHighBidder then
return false
elseif not noSeller and seller ~= self._ownerStr then
return false
elseif GetAuctionItemLink("list", index) ~= self._itemLink then
return false
elseif GetAuctionItemTimeLeft("list", index) ~= self._timeLeft then
return false
end
return true
end
function ResultSubRowWrapper.DecrementQuantity(self, amount)
if TSM.IsWowClassic() then
assert(amount == self._quantity)
self._numAuctions = self._numAuctions - 1
if self._numAuctions == 0 then
self._resultRow:RemoveSubRow(self)
end
else
if self:IsCommodity() then
self._resultRow:DecrementQuantity(amount)
else
assert(amount == 1 and amount == self._quantity)
self._numAuctions = self._numAuctions - 1
assert(self._numOwnerItems <= self._numAuctions)
if self._numAuctions == 0 then
self._resultRow:RemoveSubRow(self)
end
end
end
end
function ResultSubRowWrapper.UpdateResultInfo(self, newAuctionId, newResultInfo)
if newResultInfo then
self:_SetRawData(newResultInfo, self._browseId)
else
self._auctionId = newAuctionId
self._hash = nil
self._hashNoSeller = nil
end
end
-- ============================================================================
-- ResultRowWrapper - Private Class Methods
-- ============================================================================
function ResultSubRowWrapper._SetRawData(self, data, browseId, itemLink)
self._hash = nil
self._hashNoSeller = nil
self._browseId = browseId
if data then
if TSM.IsWowClassic() then
local _, _, stackSize, _, _, _, _, minBid, minIncrement, buyout, bid, isHighBidder, _, seller, sellerFull = GetAuctionItemInfo("list", data)
seller = Util.FixSellerName(seller, sellerFull)
-- this is to get around a bug in Blizzard's code where the minIncrement value will be inconsistent for auctions where the player is the highest bidder
minIncrement = isHighBidder and 0 or minIncrement
self._itemLink = itemLink
self._buyout = buyout
self._minBid = minBid
self._currentBid = bid
self._minIncrement = minIncrement
self._isHighBidder = isHighBidder
self._quantity = stackSize
self._timeLeft = GetAuctionItemTimeLeft("list", data)
self._ownerStr = seller or "?"
self._hasOwners = seller and true or false
self._numOwnerItems = 0
self._auctionId = 0
else
if self._resultRow:IsCommodity() then
local baseItemString = self._resultRow:GetBaseItemString()
self._itemLink = ItemInfo.GetLink(baseItemString)
else
self._itemLink = data.itemLink
end
if self:IsCommodity() then
self._quantity = data.quantity
self._buyout = data.unitPrice * data.quantity
self._minBid = self._buyout
self._currentBid = 0
self._minIncrement = 0
self._isHighBidder = data.bidder and data.bidder == UnitGUID("player") or false
self._numOwnerItems = data.numOwnerItems or 0
-- convert the timeLeftSeconds to regular timeLeft
if data.timeLeftSeconds < 60 * 60 then
self._timeLeft = 1
elseif data.timeLeftSeconds < 2 * 60 * 60 then
self._timeLeft = 2
elseif data.timeLeftSeconds < 12 * 60 * 60 then
self._timeLeft = 3
else
self._timeLeft = 4
end
else
self._quantity = 1
self._numAuctions = data.quantity
self._buyout = data.buyoutAmount or 0
self._minBid = data.minBid or data.buyoutAmount
self._currentBid = data.bidAmount or 0
self._minIncrement = 0
self._isHighBidder = false
self._numOwnerItems = data.containsAccountItem and data.quantity or 0
self._timeLeft = data.timeLeft + 1
end
self._hasOwners = #data.owners > 0
assert(#private.ownersTemp == 0)
for _, owner in ipairs(data.owners) do
if owner == "player" then
owner = UnitName("player")
elseif owner == "" then
owner = "?"
self._hasOwners = false
end
tinsert(private.ownersTemp, owner)
end
self._ownerStr = table.concat(private.ownersTemp, ",")
wipe(private.ownersTemp)
self._auctionId = data.auctionID
end
assert(self._itemLink and self._quantity and self._buyout and self._minBid and self._currentBid and self._numOwnerItems and self._timeLeft and self._ownerStr and self._auctionId)
else
self._itemLink = nil
self._buyout = nil
self._minBid = nil
self._currentBid = nil
self._minIncrement = nil
self._isHighBidder = nil
self._quantity = nil
self._timeLeft = nil
self._ownerStr = nil
self._hasOwners = false
self._numOwnerItems = nil
self._auctionId = nil
end
end

View File

@@ -0,0 +1,620 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
-- This file contains code for scanning the auction house
local _, TSM = ...
local ScanManager = TSM.Init("Service.AuctionScanClasses.ScanManager")
local L = TSM.Include("Locale").GetTable()
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local Math = TSM.Include("Util.Math")
local ObjectPool = TSM.Include("Util.ObjectPool")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local Threading = TSM.Include("Service.Threading")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Query = TSM.Include("Service.AuctionScanClasses.Query")
local QueryUtil = TSM.Include("Service.AuctionScanClasses.QueryUtil")
local AuctionScanManager = TSM.Include("LibTSMClass").DefineClass("AuctionScanManager")
local private = {
objectPool = ObjectPool.New("AUCTION_SCAN_MANAGER", AuctionScanManager),
}
-- arbitrary estimate that finishing the browse request is worth 10% of the query's progress
local BROWSE_PROGRESS = 0.1
-- ============================================================================
-- Module Functions
-- ============================================================================
function ScanManager.Get()
return private.objectPool:Get()
end
-- ============================================================================
-- Class Meta Methods
-- ============================================================================
function AuctionScanManager.__init(self)
self._resolveSellers = nil
self._ignoreItemLevel = nil
self._queries = {}
self._queriesScanned = 0
self._queryDidBrowse = false
self._onProgressUpdateHandler = nil
self._onQueryDoneHandler = nil
self._resultsUpdateCallbacks = {}
self._nextSearchItemFunction = nil
self._currentSearchChangedCallback = nil
self._findResult = {}
self._cancelled = false
self._shouldPause = false
self._paused = false
self._scanQuery = nil
self._findQuery = nil
self._numItems = nil
self._queryCallback = function(query, searchRow)
for func in pairs(self._resultsUpdateCallbacks) do
func(self, query, searchRow)
end
end
end
function AuctionScanManager._Release(self)
self._resolveSellers = nil
self._ignoreItemLevel = nil
for _, query in ipairs(self._queries) do
query:Release()
end
wipe(self._queries)
self._queriesScanned = 0
self._queryDidBrowse = false
self._onProgressUpdateHandler = nil
self._onQueryDoneHandler = nil
wipe(self._resultsUpdateCallbacks)
self._nextSearchItemFunction = nil
self._currentSearchChangedCallback = nil
self._cancelled = false
self._shouldPause = false
self._paused = false
wipe(self._findResult)
self._scanQuery = nil
if self._findQuery then
self._findQuery:Release()
self._findQuery = nil
end
self._numItems = nil
end
-- ============================================================================
-- Public Class Methods
-- ============================================================================
function AuctionScanManager.Release(self)
self:_Release()
private.objectPool:Recycle(self)
end
function AuctionScanManager.SetResolveSellers(self, resolveSellers)
self._resolveSellers = resolveSellers
return self
end
function AuctionScanManager.SetIgnoreItemLevel(self, ignoreItemLevel)
self._ignoreItemLevel = ignoreItemLevel
return self
end
function AuctionScanManager.SetScript(self, script, handler)
if script == "OnProgressUpdate" then
self._onProgressUpdateHandler = handler
elseif script == "OnQueryDone" then
self._onQueryDoneHandler = handler
elseif script == "OnCurrentSearchChanged" then
self._currentSearchChangedCallback = handler
else
error("Unknown AuctionScanManager script: "..tostring(script))
end
return self
end
function AuctionScanManager.AddResultsUpdateCallback(self, func)
self._resultsUpdateCallbacks[func] = true
end
function AuctionScanManager.RemoveResultsUpdateCallback(self, func)
self._resultsUpdateCallbacks[func] = nil
end
function AuctionScanManager.SetNextSearchItemFunction(self, func)
self._nextSearchItemFunction = func
end
function AuctionScanManager.GetNumQueries(self)
return #self._queries
end
function AuctionScanManager.QueryIterator(self, offset)
return private.QueryIteratorHelper, self._queries, offset or 0
end
function AuctionScanManager.NewQuery(self)
local query = Query.Get()
self:_AddQuery(query)
return query
end
function AuctionScanManager.AddItemListQueriesThreaded(self, itemList)
assert(Threading.IsThreadContext())
-- remove duplicates
local usedItems = TempTable.Acquire()
for i = #itemList, 1, -1 do
local itemString = itemList[i]
if usedItems[itemString] then
tremove(itemList, i)
end
usedItems[itemString] = true
end
TempTable.Release(usedItems)
self._numItems = #itemList
QueryUtil.GenerateThreaded(itemList, private.NewQueryCallback, self)
end
function AuctionScanManager.ScanQueriesThreaded(self)
assert(Threading.IsThreadContext())
self._queriesScanned = 0
self._cancelled = false
AuctionHouseWrapper.GetAndResetTotalHookedTime()
self:_NotifyProgressUpdate()
-- loop through each filter to perform
local allSuccess = true
while self._queriesScanned < #self._queries do
local query = self._queries[self._queriesScanned + 1]
-- run the browse query
local querySuccess, numNewResults = self:_ProcessQuery(query)
if not querySuccess then
allSuccess = false
break
end
self._queriesScanned = self._queriesScanned + 1
self:_NotifyProgressUpdate()
if self._onQueryDoneHandler then
self:_onQueryDoneHandler(query, numNewResults)
end
self:_Pause()
end
if allSuccess then
local hookedTime, topAddon, topTime = AuctionHouseWrapper.GetAndResetTotalHookedTime()
if hookedTime > 1 and topAddon ~= "Blizzard_AuctionHouseUI" then
Log.PrintfUser(L["Scan was slowed down by %s seconds by other AH addons (%s seconds by %s)."], Math.Round(hookedTime, 0.1), Math.Round(topTime, 0.1), topAddon)
end
end
return allSuccess
end
function AuctionScanManager.FindAuctionThreaded(self, findSubRow, noSeller)
assert(Threading.IsThreadContext())
wipe(self._findResult)
if TSM.IsWowClassic() then
return self:_FindAuctionThreaded(findSubRow, noSeller)
else
return self:_FindAuctionThreaded83(findSubRow, noSeller)
end
end
function AuctionScanManager.PrepareForBidOrBuyout(self, index, subRow, noSeller, quantity, itemBuyout)
if TSM.IsWowClassic() then
return subRow:EqualsIndex(index, noSeller)
else
local itemString = subRow:GetItemString()
if ItemInfo.IsCommodity(itemString) then
local future = AuctionHouseWrapper.StartCommoditiesPurchase(ItemString.ToId(itemString), quantity, itemBuyout)
if not future then
return false
end
return true, future
else
return true
end
end
end
function AuctionScanManager.PlaceBidOrBuyout(self, index, bidBuyout, subRow, quantity)
if TSM.IsWowClassic() then
PlaceAuctionBid("list", index, bidBuyout)
return true
else
local itemString = subRow:GetItemString()
local future = nil
if ItemInfo.IsCommodity(itemString) then
local itemId = ItemString.ToId(itemString)
future = AuctionHouseWrapper.ConfirmCommoditiesPurchase(itemId, quantity)
else
local _, auctionId = subRow:GetListingInfo()
future = AuctionHouseWrapper.PlaceBid(auctionId, bidBuyout)
quantity = 1
end
if not future then
return false
end
-- TODO: return this future and record the buyout once the future is done
future:Cancel()
return true
end
end
function AuctionScanManager.GetProgress(self)
local numQueries = self:GetNumQueries()
if self._queriesScanned == numQueries then
return 1
end
local currentQuery = self._queries[self._queriesScanned + 1]
local searchProgress = nil
if not self._queryDidBrowse or TSM.IsWowClassic() then
searchProgress = 0
else
searchProgress = currentQuery:GetSearchProgress() * (1 - BROWSE_PROGRESS) + BROWSE_PROGRESS
end
local queryStep = 1 / numQueries
local progress = min((self._queriesScanned + searchProgress) * queryStep, 1)
return progress, self._paused
end
function AuctionScanManager.Cancel(self)
self._cancelled = true
if self._scanQuery then
self._scanQuery:CancelBrowseOrSearch()
self._scanQuery = nil
end
end
function AuctionScanManager.SetPaused(self, paused)
self._shouldPause = paused
if self._scanQuery then
self._scanQuery:CancelBrowseOrSearch()
self._scanQuery = nil
end
end
function AuctionScanManager.GetNumItems(self)
return self._numItems
end
-- ============================================================================
-- Private Class Methods
-- ============================================================================
function AuctionScanManager._AddQuery(self, query)
query:SetResolveSellers(self._resolveSellers)
query:SetCallback(self._queryCallback)
tinsert(self._queries, query)
end
function AuctionScanManager._IsCancelled(self)
return self._cancelled
end
function AuctionScanManager._Pause(self)
if not self._shouldPause then
return
end
self._paused = true
self:_NotifyProgressUpdate()
if self._currentSearchChangedCallback then
self:_currentSearchChangedCallback()
end
while self._shouldPause do
Threading.Yield(true)
end
self._paused = false
self:_NotifyProgressUpdate()
if self._currentSearchChangedCallback then
self:_currentSearchChangedCallback()
end
end
function AuctionScanManager._NotifyProgressUpdate(self)
if self._onProgressUpdateHandler then
self:_onProgressUpdateHandler()
end
end
function AuctionScanManager._ProcessQuery(self, query)
local prevMaxBrowseId = 0
for _, row in query:BrowseResultsIterator() do
prevMaxBrowseId = max(prevMaxBrowseId, row:GetMinBrowseId())
end
-- run the browse query
self._queryDidBrowse = false
while not self:_DoBrowse(query) do
if self._shouldPause then
-- this browse failed due to a pause request, so try again after we're resumed
self:_Pause()
-- wipe the browse results since we're going to do another search
query:WipeBrowseResults()
else
return false, 0
end
end
self._queryDidBrowse = true
self:_NotifyProgressUpdate()
local numNewResults = 0
if TSM.IsWowClassic() then
for _, row in query:BrowseResultsIterator() do
if row:GetMinBrowseId() > prevMaxBrowseId then
numNewResults = numNewResults + row:GetNumSubRows()
end
end
return true, numNewResults
end
local rows = Threading.AcquireSafeTempTable()
for baseItemString, row in query:BrowseResultsIterator() do
rows[baseItemString] = row
end
while true do
local baseItemString, row = nil, nil
if self._nextSearchItemFunction then
baseItemString = self._nextSearchItemFunction()
row = baseItemString and rows[baseItemString]
end
if not row then
baseItemString, row = next(rows)
end
if not row then
break
end
rows[baseItemString] = nil
if self._currentSearchChangedCallback then
self:_currentSearchChangedCallback(baseItemString)
end
-- store all the existing auctionIds so we can see what changed
local prevAuctionIds = Threading.AcquireSafeTempTable()
for _, subRow in row:SubRowIterator() do
local _, auctionId = subRow:GetListingInfo()
assert(not prevAuctionIds[auctionId])
prevAuctionIds[auctionId] = true
end
-- send the query for this item
while not self:_DoSearch(query, row) do
if self._shouldPause then
-- this search failed due to a pause request, so try again after we're resumed
self:_Pause()
-- wipe the search results since we're going to do another search
row:WipeSearchResults()
else
Threading.ReleaseSafeTempTable(prevAuctionIds)
Threading.ReleaseSafeTempTable(rows)
return false, numNewResults
end
end
local numSubRows = row:GetNumSubRows()
for _, subRow in row:SubRowIterator() do
local _, auctionId = subRow:GetListingInfo()
if not prevAuctionIds[auctionId] then
numNewResults = numNewResults + 1
end
end
Threading.ReleaseSafeTempTable(prevAuctionIds)
if numSubRows == 0 then
-- remove this row since there are no search results
query:RemoveResultRow(row)
end
self:_NotifyProgressUpdate()
self:_Pause()
Threading.Yield()
end
Threading.ReleaseSafeTempTable(rows)
return true, numNewResults
end
function AuctionScanManager._DoBrowse(self, query, ...)
return self:_DoBrowseSearchHelper(query, query:Browse(...))
end
function AuctionScanManager._DoSearch(self, query, ...)
return self:_DoBrowseSearchHelper(query, query:Search(...))
end
function AuctionScanManager._DoBrowseSearchHelper(self, query, future)
if not future then
return false
end
self._scanQuery = query
local result = Threading.WaitForFuture(future)
self._scanQuery = nil
Threading.Yield()
return result
end
function AuctionScanManager._FindAuctionThreaded(self, row, noSeller)
self._cancelled = false
-- make sure we're not in the middle of a query where the results are going to change on us
Threading.WaitForFunction(CanSendAuctionQuery)
-- search the current page for the auction
if self:_FindAuctionOnCurrentPage(row, noSeller) then
Log.Info("Found on current page")
return self._findResult
end
-- search for the item
local page, maxPage = 0, nil
while true do
-- query the AH
if self._findQuery then
self._findQuery:Release()
end
local itemString = row:GetItemString()
local level = ItemInfo.GetMinLevel(itemString)
local quality = ItemInfo.GetQuality(itemString)
assert(level and quality)
self._findQuery = Query.Get()
:SetStr(ItemInfo.GetName(itemString), true)
:SetQualityRange(quality, quality)
:SetLevelRange(level, level)
:SetClass(ItemInfo.GetClassId(itemString), ItemInfo.GetSubClassId(itemString))
:SetItems(itemString)
:SetResolveSellers(not noSeller)
:SetPage(page)
local filterSuccess = self:_DoBrowse(self._findQuery)
if self._findQuery then
self._findQuery:Release()
self._findQuery = nil
end
if not filterSuccess then
break
end
-- search this page for the row
if self:_FindAuctionOnCurrentPage(row, noSeller) then
Log.Info("Found auction (%d)", page)
return self._findResult
elseif self:_IsCancelled() then
break
end
local numPages = ceil(select(2, GetNumAuctionItems("list")) / NUM_AUCTION_ITEMS_PER_PAGE)
local canBeLater = private.FindAuctionCanBeOnLaterPage(row)
maxPage = maxPage or numPages - 1
if not canBeLater and page < maxPage then
maxPage = page
end
if canBeLater and page < maxPage then
Log.Info("Trying next page (%d)", page + 1)
page = page + 1
else
return
end
end
end
function AuctionScanManager._FindAuctionOnCurrentPage(self, subRow, noSeller)
local found = false
for i = 1, GetNumAuctionItems("list") do
if subRow:EqualsIndex(i, noSeller) then
tinsert(self._findResult, i)
found = true
end
end
return found
end
function AuctionScanManager._FindAuctionThreaded83(self, findSubRow, noSeller)
assert(findSubRow:IsSubRow())
self._cancelled = false
noSeller = noSeller or findSubRow:IsCommodity()
local row = findSubRow:GetResultRow()
local findHash, findHashNoSeller = findSubRow:GetHashes()
if not self:_DoSearch(row:GetQuery(), row, false) then
return nil
end
local result = nil
-- first try to find a subRow with a full matching hash
for _, subRow in row:SubRowIterator() do
local quantity, numAuctions = subRow:GetQuantities()
local hash = subRow:GetHashes()
if hash == findHash then
result = (result or 0) + quantity * numAuctions
end
end
if result then
return result
end
-- next try to find the first subRow with a matching no-seller hash
local firstHash = nil
for _, subRow in row:SubRowIterator() do
local quantity, numAuctions = subRow:GetQuantities()
local hash, hashNoSeller = subRow:GetHashes()
if (not firstHash or hash == firstHash) and hashNoSeller == findHashNoSeller then
firstHash = hash
result = (result or 0) + quantity * numAuctions
end
end
return result
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.NewQueryCallback(query, self)
self:_AddQuery(query)
end
function private.FindAuctionCanBeOnLaterPage(row)
local pageAuctions = GetNumAuctionItems("list")
if pageAuctions == 0 then
-- there are no auctions on this page, so it cannot be on a later one
return false
end
local _, _, stackSize, _, _, _, _, _, _, buyout, _, _, _, seller, sellerFull = GetAuctionItemInfo("list", pageAuctions)
local itemBuyout = (buyout > 0) and floor(buyout / stackSize) or 0
local _, rowItemBuyout = row:GetBuyouts()
if rowItemBuyout > itemBuyout then
-- item must be on a later page since it would be sorted after the last auction on this page
return true
elseif rowItemBuyout < itemBuyout then
-- item cannot be on a later page since it would be sorted before the last auction on this page
return false
end
local rowStackSize = row:GetQuantities()
if rowStackSize > stackSize then
-- item must be on a later page since it would be sorted after the last auction on this page
return true
elseif rowStackSize < stackSize then
-- item cannot be on a later page since it would be sorted before the last auction on this page
return false
end
seller = private.FixSellerName(seller, sellerFull) or "?"
local rowSeller = row:GetOwnerInfo()
if rowSeller > seller then
-- item must be on a later page since it would be sorted after the last auction on this page
return true
elseif rowSeller < seller then
-- item cannot be on a later page since it would be sorted before the last auction on this page
return false
end
-- all the things we are sorting on are the same, so the auction could be on a later page
return true
end
function private.FixSellerName(seller, sellerFull)
local realm = GetRealmName()
if sellerFull and strjoin("-", seller, realm) ~= sellerFull then
return sellerFull
else
return seller
end
end
function private.QueryIteratorHelper(tbl, index)
index = index + 1
if index > #tbl then
return
end
return index, tbl[index]
end

View File

@@ -0,0 +1,511 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Scanner = TSM.Init("Service.AuctionScanClasses.Scanner")
local Delay = TSM.Include("Util.Delay")
local FSM = TSM.Include("Util.FSM")
local Future = TSM.Include("Util.Future")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local Event = TSM.Include("Util.Event")
local ItemInfo = TSM.Include("Service.ItemInfo")
local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper")
local Util = TSM.Include("Service.AuctionScanClasses.Util")
local ResultRow = TSM.Include("Service.AuctionScanClasses.ResultRow")
local private = {
resolveSellers = nil,
pendingFuture = nil,
query = nil,
browseResults = nil,
callback = nil,
browseId = 1,
browseIsNoScan = false,
browseIndex = 1,
browsePendingIndexes = {},
searchRow = nil,
useCachedData = nil,
retryCount = 0,
requestFuture = Future.New("AUCTION_SCANNER_FUTURE"),
requestResult = nil,
fsm = nil,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
Scanner:OnModuleLoad(function()
private.requestFuture:SetScript("OnCleanup", function()
Delay.Cancel("AUCTION_SCANNER_DONE")
private.fsm:ProcessEvent("EV_CANCEL")
end)
if TSM.IsWowClassic() then
Event.Register("AUCTION_ITEM_LIST_UPDATE", function()
private.fsm:SetLoggingEnabled(false)
private.fsm:ProcessEvent("EV_BROWSE_RESULTS_UPDATED")
private.fsm:SetLoggingEnabled(true)
end)
else
Event.Register("COMMODITY_SEARCH_RESULTS_UPDATED", function()
private.fsm:ProcessEvent("EV_SEARCH_RESULTS_UPDATED")
end)
Event.Register("ITEM_SEARCH_RESULTS_UPDATED", function()
private.fsm:ProcessEvent("EV_SEARCH_RESULTS_UPDATED")
end)
end
private.fsm = FSM.New("AUCTION_SCANNER_FSM")
:AddState(FSM.NewState("ST_INIT")
:SetOnEnter(function()
private.query = nil
private.resolveSellers = nil
private.useCachedData = nil
private.searchRow = nil
private.callback = nil
private.retryCount = 0
Delay.Cancel("AUCTION_SCANNER_RETRY")
if private.pendingFuture then
private.pendingFuture:Cancel()
private.pendingFuture = nil
end
end)
:AddTransition("ST_BROWSE_SORT")
:AddTransition("ST_BROWSE_CHECKING")
:AddTransition("ST_SEARCH_GET_KEY")
:AddEvent("EV_START_BROWSE", function(_, query, resolveSellers, browseResults, callback)
assert(not private.query)
private.query = query
private.resolveSellers = resolveSellers
private.browseResults = browseResults
private.browseId = private.browseId + 1
private.browseIsNoScan = false
private.callback = callback
return "ST_BROWSE_SORT"
end)
:AddEvent("EV_START_BROWSE_NO_SCAN", function(_, query, itemKeys, browseResults, callback)
assert(not TSM.IsWowClassic())
assert(not private.query)
private.query = query
private.browseResults = browseResults
private.browseId = private.browseId + 1
private.browseIsNoScan = true
private.callback = callback
for _, itemKey in ipairs(itemKeys) do
local baseItemString = ItemString.GetBaseFromItemKey(itemKey)
private.ProcessBrowseResult(baseItemString, itemKey)
end
return "ST_BROWSE_CHECKING"
end)
:AddEvent("EV_START_SEARCH", function(_, query, resolveSellers, useCachedData, searchRow, callback)
assert(not TSM.IsWowClassic())
assert(not private.query)
private.query = query
private.resolveSellers = resolveSellers
private.useCachedData = useCachedData
private.searchRow = searchRow
private.callback = callback
private.searchRow:SearchReset()
return "ST_SEARCH_GET_KEY"
end)
)
:AddState(FSM.NewState("ST_BROWSE_SORT")
:SetOnEnter(function()
if not private.query:_SetSort() then
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.5, private.RetryHandler)
return
end
return "ST_BROWSE_SEND"
end)
:AddTransition("ST_BROWSE_SORT")
:AddTransition("ST_BROWSE_SEND")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_RETRY", "ST_BROWSE_SORT")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_BROWSE_SEND")
:SetOnEnter(function()
private.HandleAuctionHouseWrapperResult(private.query:_SendWowQuery())
end)
:AddTransition("ST_BROWSE_SEND")
:AddTransition("ST_BROWSE_CHECKING")
:AddTransition("ST_CANCELING")
:AddEvent("EV_FUTURE_SUCCESS", function()
if TSM.IsWowClassic() then
private.browseIndex = 1
wipe(private.browsePendingIndexes)
else
for _, result in ipairs(C_AuctionHouse.GetBrowseResults()) do
local baseItemString = ItemString.GetBaseFromItemKey(result.itemKey)
private.ProcessBrowseResult(baseItemString, result.itemKey, result.minPrice, result.totalQuantity)
end
end
return "ST_BROWSE_CHECKING"
end)
:AddEventTransition("EV_RETRY", "ST_BROWSE_SEND")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_BROWSE_CHECKING")
:SetOnEnter(function()
if not private.query:_BrowseIsPageValid() then
-- this page isn't valid, so go to the next page
return "ST_BROWSE_REQUEST_MORE"
elseif not private.CheckBrowseResults() then
-- result's aren't valid yet, so check again
Delay.AfterFrame("AUCTION_SCANNER_RETRY", 1, private.RetryHandler)
return
end
-- we're done with this set of browse results
if private.callback then
private.callback(private.query)
end
if private.browseIsNoScan or private.query:_BrowseIsDone() then
-- we're done
return "ST_BROWSE_DONE"
else
-- move on to the next page
return "ST_BROWSE_REQUEST_MORE"
end
end)
:AddTransition("ST_BROWSE_CHECKING")
:AddTransition("ST_BROWSE_DONE")
:AddTransition("ST_BROWSE_REQUEST_MORE")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_RETRY", "ST_BROWSE_CHECKING")
:AddEventTransition("EV_BROWSE_RESULTS_UPDATED", "ST_BROWSE_CHECKING")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_BROWSE_REQUEST_MORE")
:SetOnEnter(function(_, isRetry)
if private.query:_BrowseIsDone(isRetry) then
return "ST_BROWSE_CHECKING"
else
private.HandleAuctionHouseWrapperResult(private.query:_BrowseRequestMore(isRetry))
end
end)
:AddTransition("ST_BROWSE_REQUEST_MORE")
:AddTransition("ST_BROWSE_CHECKING")
:AddTransition("ST_CANCELING")
:AddEvent("EV_FUTURE_SUCCESS", function(_, ...)
if TSM.IsWowClassic() then
private.browseIndex = 1
wipe(private.browsePendingIndexes)
else
local newResults = ...
for _, result in ipairs(newResults) do
local baseItemString = ItemString.GetBaseFromItemKey(result.itemKey)
private.ProcessBrowseResult(baseItemString, result.itemKey, result.minPrice, result.totalQuantity)
end
end
return "ST_BROWSE_CHECKING"
end)
:AddEvent("EV_RETRY", function()
return "ST_BROWSE_REQUEST_MORE", true
end)
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_BROWSE_DONE")
:SetOnEnter(function()
private.HandleRequestDone(true)
return "ST_INIT"
end)
:AddTransition("ST_INIT")
)
:AddState(FSM.NewState("ST_SEARCH_GET_KEY")
:SetOnEnter(function()
assert(not TSM.IsWowClassic())
if not private.searchRow:SearchIsReady() then
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.1, private.RetryHandler)
return
end
return "ST_SEARCH_SEND"
end)
:AddTransition("ST_SEARCH_GET_KEY")
:AddTransition("ST_SEARCH_SEND")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_FUTURE_SUCCESS", "ST_SEARCH_SEND")
:AddEventTransition("EV_RETRY", "ST_SEARCH_GET_KEY")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_SEARCH_SEND")
:SetOnEnter(function()
assert(not TSM.IsWowClassic())
if not AuctionHouseWrapper.IsOpen() then
return "ST_CANCELING"
end
if private.useCachedData and private.searchRow:HasCachedSearchData() then
return "ST_SEARCH_REQUEST_MORE"
end
local future, delayTime = private.searchRow:SearchSend()
if future then
private.HandleAuctionHouseWrapperResult(future)
else
if not delayTime then
Log.Err("Failed to send search query - retrying")
delayTime = 0.5
end
-- try again after a delay
Delay.AfterTime("AUCTION_SCANNER_RETRY", delayTime, private.RetryHandler)
end
end)
:AddTransition("ST_SEARCH_SEND")
:AddTransition("ST_SEARCH_REQUEST_MORE")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_FUTURE_SUCCESS", "ST_SEARCH_REQUEST_MORE")
:AddEventTransition("EV_RETRY", "ST_SEARCH_SEND")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_SEARCH_REQUEST_MORE")
:SetOnEnter(function()
assert(not TSM.IsWowClassic())
local baseItemString = private.searchRow:GetBaseItemString()
-- get if the item is a commodity or not
local isCommodity = ItemInfo.IsCommodity(baseItemString)
if isCommodity == nil then
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.1, private.RetryHandler)
return
end
local isDone, future = private.searchRow:SearchCheckStatus()
if isDone then
return "ST_SEARCH_CHECKING"
elseif future then
private.HandleAuctionHouseWrapperResult(future)
else
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.5, private.RetryHandler)
end
end)
:AddTransition("ST_SEARCH_SEND")
:AddTransition("ST_SEARCH_CHECKING")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_FUTURE_SUCCESS", "ST_SEARCH_CHECKING")
:AddEventTransition("EV_RETRY", "ST_SEARCH_SEND")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_SEARCH_CHECKING")
:SetOnEnter(function()
assert(not TSM.IsWowClassic())
Delay.Cancel("AUCTION_SCANNER_RETRY")
private.searchRow:PopulateSubRows(private.browseId)
-- check if all the sub rows have their data
local isDone = true
for _, subRow in private.searchRow:SubRowIterator(true) do
if not subRow:HasRawData() or not subRow:HasItemString() then
isDone = false
elseif private.resolveSellers and not subRow:HasOwners() and not private.query:_IsFiltered(subRow, true) then
-- waiting for owner info
isDone = false
end
end
if not isDone and private.retryCount >= 100 then
-- out of retries, so give up
return "ST_SEARCH_DONE", false
elseif not isDone then
-- we'll try again
private.retryCount = private.retryCount + 1
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.5, private.RetryHandler)
return
end
-- filter the sub rows we don't care about
private.searchRow:FilterSubRows(private.query)
if private.callback then
private.callback(private.query, private.searchRow)
end
if private.searchRow:SearchNext() then
-- there is more to search
return "ST_SEARCH_GET_KEY"
else
-- scanned everything
return "ST_SEARCH_DONE", true
end
end)
:AddTransition("ST_SEARCH_GET_KEY")
:AddTransition("ST_SEARCH_CHECKING")
:AddTransition("ST_SEARCH_DONE")
:AddTransition("ST_CANCELING")
:AddEventTransition("EV_RETRY", "ST_SEARCH_CHECKING")
:AddEventTransition("EV_SEARCH_RESULTS_UPDATED", "ST_SEARCH_CHECKING")
:AddEventTransition("EV_CANCEL", "ST_CANCELING")
)
:AddState(FSM.NewState("ST_SEARCH_DONE")
:SetOnEnter(function(_, result)
assert(not TSM.IsWowClassic())
private.HandleRequestDone(result)
return "ST_INIT"
end)
:AddTransition("ST_INIT")
)
:AddState(FSM.NewState("ST_CANCELING")
:SetOnEnter(function()
Delay.Cancel("AUCTION_SCANNER_DONE")
return "ST_INIT"
end)
:AddTransition("ST_INIT")
)
:Init("ST_INIT", nil)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Scanner.Browse(query, resolveSellers, results, callback)
private.requestFuture:Start()
private.fsm:ProcessEvent("EV_START_BROWSE", query, resolveSellers, results, callback)
return private.requestFuture
end
function Scanner.BrowseNoScan(query, itemKeys, results, callback)
assert(not TSM.IsWowClassic())
private.requestFuture:Start()
private.fsm:ProcessEvent("EV_START_BROWSE_NO_SCAN", query, itemKeys, results, callback)
return private.requestFuture
end
function Scanner.Search(query, resolveSellers, useCachedData, browseRow, callback)
assert(not TSM.IsWowClassic())
private.requestFuture:Start()
private.fsm:ProcessEvent("EV_START_SEARCH", query, resolveSellers, useCachedData, browseRow, callback)
return private.requestFuture
end
function Scanner.Cancel()
private.requestFuture:Done(false)
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ProcessBrowseResult(baseItemString, ...)
if private.browseResults[baseItemString] then
private.browseResults[baseItemString]:Merge(...)
else
private.browseResults[baseItemString] = ResultRow.Get(private.query, ...)
end
return private.browseResults[baseItemString]
end
function private.PendingFutureDoneHandler()
local result = private.pendingFuture:GetValue()
private.pendingFuture = nil
if result then
private.fsm:ProcessEvent("EV_FUTURE_SUCCESS", result)
else
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.1, private.RetryHandler)
end
end
function private.RetryHandler()
private.fsm:SetLoggingEnabled(false)
private.fsm:ProcessEvent("EV_RETRY")
private.fsm:SetLoggingEnabled(true)
end
function private.RequestDoneHandler()
local result = private.requestResult
private.requestResult = nil
private.requestFuture:Done(result)
end
function private.HandleAuctionHouseWrapperResult(future)
if future then
private.pendingFuture = future
private.pendingFuture:SetScript("OnDone", private.PendingFutureDoneHandler)
else
Delay.AfterTime("AUCTION_SCANNER_RETRY", 0.1, private.RetryHandler)
end
end
function private.HandleRequestDone(result)
private.requestResult = result
-- delay a bit so that we complete our current FSM transition
Delay.AfterTime("AUCTION_SCANNER_DONE", 0, private.RequestDoneHandler)
end
function private.CheckBrowseResults()
if TSM.IsWowClassic() then
-- process as many auctions as we can
local numAuctions = GetNumAuctionItems("list")
for i = #private.browsePendingIndexes, 1, -1 do
local index = private.browsePendingIndexes[i]
if private.ProcessBrowseResultClassic(index) then
tremove(private.browsePendingIndexes, i)
end
end
local index = private.browseIndex
while index <= numAuctions and #private.browsePendingIndexes < 50 do
if not private.ProcessBrowseResultClassic(index) then
tinsert(private.browsePendingIndexes, index)
end
index = index + 1
end
private.browseIndex = index
if private.browseIndex <= numAuctions or #private.browsePendingIndexes > 0 then
return false
end
end
-- check if there's data still pending
local hasPendingData = false
for _, row in pairs(private.browseResults) do
if not row:PopulateBrowseData() then
hasPendingData = true
-- keep going so we issue requests for all pending rows
end
end
if hasPendingData then
return false
end
-- filter the results
local numRemoved = 0
for baseItemString, row in pairs(private.browseResults) do
-- filter the itemKeys we don't care about and rows which don't match the query
if row:IsFiltered(private.query) then
private.browseResults[baseItemString] = nil
numRemoved = numRemoved + 1
end
if TSM.IsWowClassic() then
if row:FilterSubRows(private.query) then
-- no more subRows, so filter the entire row
private.browseResults[baseItemString] = nil
numRemoved = numRemoved + 1
end
end
end
if numRemoved > 0 then
Log.Info("Removed %d results", numRemoved)
end
return true
end
function private.ProcessBrowseResultClassic(index)
local rawName, _, stackSize, _, _, _, _, _, _, buyout, _, _, _, seller, sellerFull = GetAuctionItemInfo("list", index)
local itemLink = GetAuctionItemLink("list", index)
local baseItemString = ItemString.GetBase(itemLink)
local timeLeft = GetAuctionItemTimeLeft("list", index)
seller = Util.FixSellerName(seller, sellerFull)
if not rawName or rawName == "" or not baseItemString or not buyout or not stackSize or not timeLeft or (not seller and private.resolveSellers) then
return false
end
local row = private.ProcessBrowseResult(baseItemString, itemLink)
-- amazingly, GetAuctionItemLink could return nil the next time it's called (within the same frame), so pass through our itemLink
row:PopulateSubRows(private.browseId, index, itemLink)
return true
end

View File

@@ -0,0 +1,38 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Util = TSM.Init("Service.AuctionScanClasses.Util")
local ItemInfo = TSM.Include("Service.ItemInfo")
-- ============================================================================
-- Module Functions
-- ============================================================================
function Util.HasItemInfo(itemString)
local itemName = ItemInfo.GetName(itemString)
local itemLevel = ItemInfo.GetItemLevel(itemString)
local quality = ItemInfo.GetQuality(itemString)
local minLevel = ItemInfo.GetMinLevel(itemString)
local hasIsCommodity = TSM.IsWowClassic() or ItemInfo.IsCommodity(itemString) ~= nil
local hasCanHaveVariations = ItemInfo.CanHaveVariations(itemString) ~= nil
local result = itemName and itemLevel and quality and minLevel and hasIsCommodity and hasCanHaveVariations
if not result then
ItemInfo.FetchInfo(itemString)
end
return result
end
function Util.FixSellerName(seller, sellerFull)
local realm = GetRealmName()
if sellerFull and strjoin("-", seller, realm) ~= sellerFull then
return sellerFull
else
return seller
end
end

View File

@@ -0,0 +1,626 @@
-- ------------------------------------------------------------------------------ --
-- 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

View File

@@ -0,0 +1,615 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local BagTracking = TSM.Init("Service.BagTracking")
local Database = TSM.Include("Util.Database")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local SlotId = TSM.Include("Util.SlotId")
local Log = TSM.Include("Util.Log")
local TempTable = TSM.Include("Util.TempTable")
local ItemString = TSM.Include("Util.ItemString")
local ItemInfo = TSM.Include("Service.ItemInfo")
local InventoryInfo = TSM.Include("Service.InventoryInfo")
local Settings = TSM.Include("Service.Settings")
local private = {
slotDB = nil,
quantityDB = nil,
settings = nil,
bagUpdates = {
pending = {},
bagList = {},
bankList = {},
},
bankSlotUpdates = {
pending = {},
list = {},
},
reagentBankSlotUpdates = {
pending = {},
list = {},
},
bankOpen = false,
isFirstBankOpen = true,
callbackQuery = nil, -- luacheck: ignore 1004 - just stored for GC reasons
callbacks = {},
}
local BANK_BAG_SLOTS = {}
local BANK_NON_REAGENT_BAG_SLOTS = {}
-- ============================================================================
-- Population of the Static Data
-- ============================================================================
do
BANK_BAG_SLOTS[BANK_CONTAINER] = true
BANK_NON_REAGENT_BAG_SLOTS[BANK_CONTAINER] = true
for i = NUM_BAG_SLOTS + 1, NUM_BAG_SLOTS + NUM_BANKBAGSLOTS do
BANK_BAG_SLOTS[i] = true
BANK_NON_REAGENT_BAG_SLOTS[i] = true
end
if not TSM.IsWowClassic() then
BANK_BAG_SLOTS[REAGENTBANK_CONTAINER] = true
end
end
-- ============================================================================
-- Module Loading
-- ============================================================================
BagTracking:OnSettingsLoad(function()
Event.Register("BAG_UPDATE", private.BagUpdateHandler)
Event.Register("BAG_UPDATE_DELAYED", private.BagUpdateDelayedHandler)
Event.Register("BANKFRAME_OPENED", private.BankOpenedHandler)
Event.Register("BANKFRAME_CLOSED", private.BankClosedHandler)
Event.Register("PLAYERBANKSLOTS_CHANGED", private.BankSlotChangedHandler)
if not TSM.IsWowClassic() then
Event.Register("PLAYERREAGENTBANKSLOTS_CHANGED", private.ReagentBankSlotChangedHandler)
end
private.slotDB = Database.NewSchema("BAG_TRACKING_SLOTS")
:AddUniqueNumberField("slotId")
:AddNumberField("bag")
:AddNumberField("slot")
:AddStringField("itemLink")
:AddStringField("itemString")
:AddSmartMapField("baseItemString", ItemString.GetBaseMap(), "itemString")
:AddNumberField("itemTexture")
:AddNumberField("quantity")
:AddBooleanField("isBoP")
:AddBooleanField("isBoA")
:AddIndex("slotId")
:AddIndex("bag")
:AddIndex("itemString")
:AddIndex("baseItemString")
:Commit()
private.quantityDB = Database.NewSchema("BAG_TRACKING_QUANTITY")
:AddUniqueStringField("itemString")
:AddNumberField("bagQuantity")
:AddNumberField("bankQuantity")
:AddNumberField("reagentBankQuantity")
:Commit()
private.callbackQuery = private.slotDB:NewQuery()
:SetUpdateCallback(private.OnCallbackQueryUpdated)
private.settings = Settings.NewView()
:AddKey("sync", "internalData", "bagQuantity")
:AddKey("sync", "internalData", "bankQuantity")
:AddKey("sync", "internalData", "reagentBankQuantity")
local items = TempTable.Acquire()
local bagQuantity = TempTable.Acquire()
local bankQuantity = TempTable.Acquire()
local reagentBankQuantity = TempTable.Acquire()
for itemString, quantity in pairs(private.settings.bagQuantity) do
if itemString == ItemString.GetBase(itemString) then
items[itemString] = true
bagQuantity[itemString] = quantity
else
private.settings.bagQuantity[itemString] = nil
end
end
for itemString, quantity in pairs(private.settings.bankQuantity) do
if itemString == ItemString.GetBase(itemString) then
items[itemString] = true
bankQuantity[itemString] = quantity
else
private.settings.bankQuantity[itemString] = nil
end
end
for itemString, quantity in pairs(private.settings.reagentBankQuantity) do
if itemString == ItemString.GetBase(itemString) then
items[itemString] = true
reagentBankQuantity[itemString] = quantity
else
private.settings.reagentBankQuantity[itemString] = nil
end
end
private.quantityDB:BulkInsertStart()
for itemString in pairs(items) do
local total = (bagQuantity[itemString] or 0) + (bankQuantity[itemString] or 0) + (reagentBankQuantity[itemString] or 0)
if total > 0 then
private.quantityDB:BulkInsertNewRow(itemString, bagQuantity[itemString] or 0, bankQuantity[itemString] or 0, reagentBankQuantity[itemString] or 0)
end
end
private.quantityDB:BulkInsertEnd()
TempTable.Release(items)
TempTable.Release(bagQuantity)
TempTable.Release(bankQuantity)
TempTable.Release(reagentBankQuantity)
end)
BagTracking:OnGameDataLoad(function()
-- we'll scan all the bags and reagent bank right away, so wipe the existing quantities
wipe(private.settings.bagQuantity)
wipe(private.settings.reagentBankQuantity)
private.quantityDB:SetQueryUpdatesPaused(true)
local query = private.quantityDB:NewQuery()
for _, row in query:Iterator() do
local oldValue = row:GetField("bagQuantity") + row:GetField("reagentBankQuantity")
if row:GetField("bankQuantity") == 0 then
-- remove this row
assert(oldValue > 0)
private.quantityDB:DeleteRow(row)
elseif oldValue ~= 0 then
-- update this row
row:SetField("bagQuantity", 0)
:SetField("reagentBankQuantity", 0)
:Update()
end
end
query:Release()
private.quantityDB:SetQueryUpdatesPaused(false)
-- WoW does not fire an update event for the backpack when you log in, so trigger one
private.BagUpdateHandler(nil, 0)
private.BagUpdateDelayedHandler()
-- trigger an update event for all bank (initial container) and reagent bank slots since we won't get one otherwise on login
assert(GetContainerNumSlots(BANK_CONTAINER) == NUM_BANKGENERIC_SLOTS)
for slot = 1, GetContainerNumSlots(BANK_CONTAINER) do
private.BankSlotChangedHandler(nil, slot)
end
if not TSM.IsWowClassic() and IsReagentBankUnlocked() then
for slot = 1, GetContainerNumSlots(REAGENTBANK_CONTAINER) do
private.ReagentBankSlotChangedHandler(nil, slot)
end
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function BagTracking.RegisterCallback(callback)
tinsert(private.callbacks, callback)
end
function BagTracking.BaseItemIterator()
return private.quantityDB:NewQuery()
:Select("itemString")
:IteratorAndRelease()
end
function BagTracking.FilterQueryBags(query)
return query
:GreaterThanOrEqual("slotId", SlotId.Join(0, 1))
:LessThanOrEqual("slotId", SlotId.Join(NUM_BAG_SLOTS + 1, 0))
end
function BagTracking.CreateQueryBags()
return BagTracking.FilterQueryBags(private.slotDB:NewQuery())
end
function BagTracking.CreateQueryBagsAuctionable()
return BagTracking.CreateQueryBags()
:Equal("isBoP", false)
:Equal("isBoA", false)
:Custom(private.NoUsedChargesQueryFilter)
end
function BagTracking.CreateQueryBagsItem(itemString)
local query = BagTracking.CreateQueryBags()
if itemString == ItemString.GetBaseFast(itemString) then
query:Equal("baseItemString", itemString)
else
query:Equal("itemString", itemString)
end
return query
end
function BagTracking.CreateQueryBagsItemAuctionable(itemString)
return BagTracking.CreateQueryBagsItem(itemString)
:Equal("isBoP", false)
:Equal("isBoA", false)
:Custom(private.NoUsedChargesQueryFilter)
end
function BagTracking.GetNumMailable(itemString)
return BagTracking.CreateQueryBagsItem(itemString)
:Equal("isBoP", false)
:SumAndRelease("quantity") or 0
end
function BagTracking.CreateQueryBank()
return private.slotDB:NewQuery()
:InTable("bag", BANK_BAG_SLOTS)
end
function BagTracking.CreateQueryBankItem(itemString)
local query = BagTracking.CreateQueryBank()
if itemString == ItemString.GetBaseFast(itemString) then
query:Equal("baseItemString", itemString)
else
query:Equal("itemString", itemString)
end
return query
end
function BagTracking.ForceBankQuantityDeduction(itemString, quantity)
if private.bankOpen then
return
end
private.slotDB:SetQueryUpdatesPaused(true)
local query = private.slotDB:NewQuery()
:Equal("itemString", itemString)
:InTable("bag", BANK_NON_REAGENT_BAG_SLOTS)
local baseItemString = ItemString.GetBaseFast(itemString)
for _, row in query:Iterator() do
if quantity > 0 then
local rowQuantity, rowBag = row:GetFields("quantity", "bag")
if rowQuantity <= quantity then
private.ChangeBagItemTotal(rowBag, baseItemString, -rowQuantity)
private.slotDB:DeleteRow(row)
quantity = quantity - rowQuantity
else
row:SetField("quantity", rowQuantity - quantity)
:Update()
private.ChangeBagItemTotal(rowBag, baseItemString, -quantity)
quantity = 0
end
end
end
query:Release()
private.slotDB:SetQueryUpdatesPaused(false)
end
function BagTracking.GetQuantityBySlotId(slotId)
return private.slotDB:GetUniqueRowField("slotId", slotId, "quantity")
end
function BagTracking.GetBagsQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "bagQuantity") or 0
end
function BagTracking.GetBankQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "bankQuantity") or 0
end
function BagTracking.GetReagentBankQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "reagentBankQuantity") or 0
end
-- ============================================================================
-- Event Handlers
-- ============================================================================
function private.BankOpenedHandler()
if private.isFirstBankOpen then
private.isFirstBankOpen = false
-- this is the first time opening the bank so we'll scan all the items so wipe our existing quantities
wipe(private.settings.bankQuantity)
private.quantityDB:SetQueryUpdatesPaused(true)
local query = private.quantityDB:NewQuery()
for _, row in query:Iterator() do
local oldValue = row:GetField("bankQuantity")
if row:GetField("bagQuantity") + row:GetField("reagentBankQuantity") == 0 then
-- remove this row
assert(oldValue > 0)
private.quantityDB:DeleteRow(row)
elseif oldValue ~= 0 then
-- update this row
row:SetField("bankQuantity", 0)
:Update()
end
end
query:Release()
private.quantityDB:SetQueryUpdatesPaused(false)
end
private.bankOpen = true
private.BagUpdateDelayedHandler()
private.BankSlotUpdateDelayed()
end
function private.BankClosedHandler()
private.bankOpen = false
end
function private.BagUpdateHandler(_, bag)
if private.bagUpdates.pending[bag] then
return
end
private.bagUpdates.pending[bag] = true
if bag >= BACKPACK_CONTAINER and bag <= NUM_BAG_SLOTS then
tinsert(private.bagUpdates.bagList, bag)
elseif bag == BANK_CONTAINER or (bag > NUM_BAG_SLOTS and bag <= NUM_BAG_SLOTS + NUM_BANKBAGSLOTS) then
tinsert(private.bagUpdates.bankList, bag)
elseif bag ~= KEYRING_CONTAINER then
error("Unexpected bag: "..tostring(bag))
end
end
function private.BagUpdateDelayedHandler()
private.slotDB:SetQueryUpdatesPaused(true)
-- scan any pending bags
for i = #private.bagUpdates.bagList, 1, -1 do
local bag = private.bagUpdates.bagList[i]
if private.ScanBagOrBank(bag) then
private.bagUpdates.pending[bag] = nil
tremove(private.bagUpdates.bagList, i)
end
end
if #private.bagUpdates.bagList > 0 then
-- some failed to scan so try again
Delay.AfterFrame("bagBankScan", 2, private.BagUpdateDelayedHandler)
end
if private.bankOpen then
-- scan any pending bank bags
for i = #private.bagUpdates.bankList, 1, -1 do
local bag = private.bagUpdates.bankList[i]
if private.ScanBagOrBank(bag) then
private.bagUpdates.pending[bag] = nil
tremove(private.bagUpdates.bankList, i)
end
end
if #private.bagUpdates.bankList > 0 then
-- some failed to scan so try again
Delay.AfterFrame("bagBankScan", 2, private.BagUpdateDelayedHandler)
end
end
private.slotDB:SetQueryUpdatesPaused(false)
end
function private.BankSlotChangedHandler(_, slot)
if slot > NUM_BANKGENERIC_SLOTS then
private.BagUpdateHandler(nil, slot - NUM_BANKGENERIC_SLOTS)
return
end
if private.bankSlotUpdates.pending[slot] then
return
end
private.bankSlotUpdates.pending[slot] = true
tinsert(private.bankSlotUpdates.list, slot)
Delay.AfterFrame("bankSlotScan", 2, private.BankSlotUpdateDelayed)
end
-- this is not a WoW event, but we fake it based on a delay from private.BankSlotChangedHandler
function private.BankSlotUpdateDelayed()
if not private.bankOpen then
return
end
private.slotDB:SetQueryUpdatesPaused(true)
-- scan any pending slots
for i = #private.bankSlotUpdates.list, 1, -1 do
local slot = private.bankSlotUpdates.list[i]
if private.ScanBankSlot(slot) then
private.bankSlotUpdates.pending[slot] = nil
tremove(private.bankSlotUpdates.list, i)
end
end
if #private.bankSlotUpdates.list > 0 then
-- some failed to scan so try again
Delay.AfterFrame("bankSlotScan", 2, private.BankSlotUpdateDelayed)
end
private.slotDB:SetQueryUpdatesPaused(false)
end
function private.ReagentBankSlotChangedHandler(_, slot)
if private.reagentBankSlotUpdates.pending[slot] then
return
end
private.reagentBankSlotUpdates.pending[slot] = true
tinsert(private.reagentBankSlotUpdates.list, slot)
Delay.AfterFrame("reagentBankSlotScan", 2, private.ReagentBankSlotUpdateDelayed)
end
-- this is not a WoW event, but we fake it based on a delay from private.ReagentBankSlotChangedHandler
function private.ReagentBankSlotUpdateDelayed()
private.slotDB:SetQueryUpdatesPaused(true)
-- scan any pending slots
for i = #private.reagentBankSlotUpdates.list, 1, -1 do
local slot = private.reagentBankSlotUpdates.list[i]
if private.ScanReagentBankSlot(slot) then
private.reagentBankSlotUpdates.pending[slot] = nil
tremove(private.reagentBankSlotUpdates.list, i)
end
end
if #private.reagentBankSlotUpdates.list > 0 then
-- some failed to scan so try again
Delay.AfterFrame("reagentBankSlotScan", 2, private.ReagentBankSlotUpdateDelayed)
end
private.slotDB:SetQueryUpdatesPaused(false)
end
-- ============================================================================
-- Scanning Functions
-- ============================================================================
function private.ScanBagOrBank(bag)
local numSlots = GetContainerNumSlots(bag)
private.RemoveExtraSlots(bag, numSlots)
local result = true
for slot = 1, numSlots do
if not private.ScanBagSlot(bag, slot) then
result = false
end
end
return result
end
function private.ScanBankSlot(slot)
return private.ScanBagSlot(BANK_CONTAINER, slot)
end
function private.ScanReagentBankSlot(slot)
return private.ScanBagSlot(REAGENTBANK_CONTAINER, slot)
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.NoUsedChargesQueryFilter(row)
return not InventoryInfo.HasUsedCharges(row:GetFields("bag", "slot"))
end
function private.RemoveExtraSlots(bag, numSlots)
-- the number of slots of this bag may have changed, in which case we should remove any higher ones from our DB
local query = private.slotDB:NewQuery()
:Equal("bag", bag)
:GreaterThan("slot", numSlots)
for _, row in query:Iterator() do
local baseItemString, quantity = row:GetFields("baseItemString", "quantity")
private.ChangeBagItemTotal(bag, baseItemString, -quantity)
private.slotDB:DeleteRow(row)
end
query:Release()
end
function private.ScanBagSlot(bag, slot)
local texture, quantity, _, _, _, _, link, _, _, itemId = GetContainerItemInfo(bag, slot)
if quantity and not itemId then
-- we are pending item info for this slot so try again later to scan it
return false
elseif quantity == 0 then
-- this item is going away, so try again later to scan it
return false
end
local baseItemString = link and ItemString.GetBase(link)
local slotId = SlotId.Join(bag, slot)
local row = private.slotDB:GetUniqueRow("slotId", slotId)
if baseItemString then
local isBoP, isBoA = nil, nil
if row then
if row:GetField("itemLink") == link then
-- the item didn't change, so use the previous values
isBoP, isBoA = row:GetFields("isBoP", "isBoA")
else
isBoP, isBoA = InventoryInfo.IsSoulbound(bag, slot)
if isBoP == nil then
Log.Err("Failed to get soulbound info for %d,%d (%s)", bag, slot, link or "?")
return false
end
end
-- remove the old row from the item totals
local oldBaseItemString, oldQuantity = row:GetFields("baseItemString", "quantity")
private.ChangeBagItemTotal(bag, oldBaseItemString, -oldQuantity)
else
isBoP, isBoA = InventoryInfo.IsSoulbound(bag, slot)
if isBoP == nil then
Log.Err("Failed to get soulbound info for %d,%d (%s)", bag, slot, link or "?")
return false
end
-- there was nothing here previously so create a new row
row = private.slotDB:NewRow()
:SetField("slotId", slotId)
:SetField("bag", bag)
:SetField("slot", slot)
end
-- update the row
row:SetField("itemLink", link)
:SetField("itemString", ItemString.Get(link))
:SetField("itemTexture", texture or ItemInfo.GetTexture(link))
:SetField("quantity", quantity)
:SetField("isBoP", isBoP)
:SetField("isBoA", isBoA)
:CreateOrUpdateAndRelease()
-- add to the item totals
private.ChangeBagItemTotal(bag, baseItemString, quantity)
elseif row then
-- nothing here now so delete the row and remove from the item totals
local oldBaseItemString, oldQuantity = row:GetFields("baseItemString", "quantity")
private.ChangeBagItemTotal(bag, oldBaseItemString, -oldQuantity)
private.slotDB:DeleteRow(row)
row:Release()
end
return true
end
function private.OnCallbackQueryUpdated()
for _, callback in ipairs(private.callbacks) do
callback()
end
end
function private.ChangeBagItemTotal(bag, itemString, changeQuantity)
local totalsTable = nil
local field = nil
if bag >= BACKPACK_CONTAINER and bag <= NUM_BAG_SLOTS then
totalsTable = private.settings.bagQuantity
field = "bagQuantity"
elseif bag == BANK_CONTAINER or (bag > NUM_BAG_SLOTS and bag <= NUM_BAG_SLOTS + NUM_BANKBAGSLOTS) then
totalsTable = private.settings.bankQuantity
field = "bankQuantity"
elseif bag == REAGENTBANK_CONTAINER then
totalsTable = private.settings.reagentBankQuantity
field = "reagentBankQuantity"
else
error("Unexpected bag: "..tostring(bag))
end
totalsTable[itemString] = (totalsTable[itemString] or 0) + changeQuantity
private.UpdateQuantity(itemString, field, changeQuantity)
assert(totalsTable[itemString] >= 0)
if totalsTable[itemString] == 0 then
totalsTable[itemString] = nil
end
end
function private.UpdateQuantity(itemString, field, quantity)
assert(itemString and field and quantity)
assert(quantity ~= 0)
if not private.quantityDB:HasUniqueRow("itemString", itemString) then
-- create a new row
private.quantityDB:NewRow()
:SetField("itemString", itemString)
:SetField("bagQuantity", 0)
:SetField("bankQuantity", 0)
:SetField("reagentBankQuantity", 0)
:Create()
end
local row = private.quantityDB:GetUniqueRow("itemString", itemString)
local totalQuantity = row:GetField("bagQuantity") + row:GetField("bankQuantity") + row:GetField("reagentBankQuantity")
local oldValue = row:GetField(field)
local newValue = oldValue + quantity
assert(newValue >= 0)
if newValue == 0 and totalQuantity == oldValue then
-- remove this row
private.quantityDB:DeleteRow(row)
else
-- update this row
row:SetField(field, oldValue + quantity)
:Update()
end
row:Release()
end

View File

@@ -0,0 +1,67 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
-- This file contains all the code for TSM's standalone features
local _, TSM = ...
local BlackMarket = TSM.Init("Service.BlackMarket")
local Event = TSM.Include("Util.Event")
local TempTable = TSM.Include("Util.TempTable")
local ItemString = TSM.Include("Util.ItemString")
local private = {
data = nil,
time = nil,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
BlackMarket:OnModuleLoad(function()
-- setup BMAH scanning
if not TSM.IsWowClassic() then
Event.Register("BLACK_MARKET_ITEM_UPDATE", private.ScanBMAH)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function BlackMarket.GetScanData()
return private.data, private.time
end
-- ============================================================================
-- Private Helper Features
-- ============================================================================
function private.ScanBMAH()
local numItems = C_BlackMarket.GetNumItems()
if not numItems then
return
end
local items = TempTable.Acquire()
for i = 1, numItems do
local _, _, quantity, _, _, _, _, _, minBid, minIncr, currBid, _, numBids, timeLeft, itemLink, bmId = C_BlackMarket.GetItemInfoByIndex(i)
local itemID = ItemString.ToId(itemLink)
if itemID then
minBid = floor(minBid / COPPER_PER_GOLD)
minIncr = floor(minIncr / COPPER_PER_GOLD)
currBid = floor(currBid / COPPER_PER_GOLD)
tinsert(items, "[" .. strjoin(",", bmId, itemID, quantity, timeLeft, minBid, minIncr, currBid, numBids, time()) .. "]")
end
end
private.data = "[" .. table.concat(items, ",") .. "]"
private.time = time()
TempTable.Release(items)
end

View File

@@ -0,0 +1,182 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Conversions = TSM.Init("Service.Conversions")
local DisenchantInfo = TSM.Include("Data.DisenchantInfo")
local Mill = TSM.Include("Data.Mill")
local Prospect = TSM.Include("Data.Prospect")
local Transform = TSM.Include("Data.Transform")
local VendorTrade = TSM.Include("Data.VendorTrade")
local TempTable = TSM.Include("Util.TempTable")
local ItemString = TSM.Include("Util.ItemString")
local Table = TSM.Include("Util.Table")
local ItemInfo = TSM.Include("Service.ItemInfo")
Conversions.METHOD = {
DISENCHANT = newproxy(),
MILL = newproxy(),
PROSPECT = newproxy(),
TRANSFORM = newproxy(),
VENDOR_TRADE = newproxy(),
CRAFT = newproxy(),
}
local private = {
data = {},
sourceItemCache = {},
skippedConversions = {},
}
local MAX_CONVERSION_DEPTH = 3
local EMPTY_CONVERSION = newproxy()
-- ============================================================================
-- Module Loading
-- ============================================================================
Conversions:OnModuleLoad(function()
Table.SetReadOnly(Conversions.METHOD)
for itemString in DisenchantInfo.TargetItemIterator() do
ItemInfo.FetchInfo(itemString)
end
for targetItemString in Mill.TargetItemIterator() do
for sourceItemString in Mill.SourceItemIterator(targetItemString) do
local rate = Mill.GetRate(targetItemString, sourceItemString)
private.Add(targetItemString, sourceItemString, Conversions.METHOD.MILL, rate)
end
end
for targetItemString in Prospect.TargetItemIterator() do
for sourceItemString in Prospect.SourceItemIterator(targetItemString) do
local rate, amount, minAmount, maxAmount = Prospect.GetRate(targetItemString, sourceItemString)
private.Add(targetItemString, sourceItemString, Conversions.METHOD.PROSPECT, rate, amount, minAmount, maxAmount)
end
end
for targetItemString in Transform.TargetItemIterator() do
for sourceItemString in Transform.SourceItemIterator(targetItemString) do
local rate = Transform.GetRate(targetItemString, sourceItemString)
private.Add(targetItemString, sourceItemString, Conversions.METHOD.TRANSFORM, rate)
end
end
for targetItemString in VendorTrade.TargetItemIterator() do
for sourceItemString in VendorTrade.SourceItemIterator(targetItemString) do
local rate = VendorTrade.GetRate(targetItemString, sourceItemString)
private.Add(targetItemString, sourceItemString, Conversions.METHOD.VENDOR_TRADE, rate)
end
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Conversions.AddCraft(targetItemString, sourceItemString, rate)
private.Add(targetItemString, sourceItemString, Conversions.METHOD.CRAFT, rate)
end
function Conversions.TargetItemsByMethodIterator(sourceItemString, method)
local context = TempTable.Acquire()
context.sourceItemString = sourceItemString
context.method = method
return private.TargetItemsByMethodIteratorHelper, context, nil
end
function Conversions.GetTargetItemByName(targetItemName)
targetItemName = strlower(targetItemName)
for targetItemString in pairs(private.data) do
local name = ItemInfo.GetName(targetItemString)
if name and strlower(name) == targetItemName then
return targetItemString
end
end
for targetItemString in DisenchantInfo.TargetItemIterator() do
local name = ItemInfo.GetName(targetItemString)
if name and strlower(name) == targetItemName then
return targetItemString
end
end
end
function Conversions.GetSourceItems(targetItemString)
if not targetItemString or not private.data[targetItemString] or private.sourceItemCache[targetItemString] == EMPTY_CONVERSION then
return
end
if not private.sourceItemCache[targetItemString] then
local depthLookup = TempTable.Acquire()
depthLookup[targetItemString] = -1 -- set this so we don't loop back through the target item
private.sourceItemCache[targetItemString] = {}
private.GetSourceItemsHelper(targetItemString, private.sourceItemCache[targetItemString], depthLookup, 0, 1)
TempTable.Release(depthLookup)
if not next(private.sourceItemCache[targetItemString]) then
private.sourceItemCache[targetItemString] = EMPTY_CONVERSION
return
end
end
return private.sourceItemCache[targetItemString]
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.Add(targetItemString, sourceItemString, method, rate, amount, minAmount, maxAmount)
targetItemString = ItemString.GetBase(targetItemString)
sourceItemString = ItemString.GetBase(sourceItemString)
assert(targetItemString and sourceItemString)
private.data[targetItemString] = private.data[targetItemString] or {}
if private.data[targetItemString][sourceItemString] then
-- if there is more than one way to go from source to target, then just skip all conversions between these items
private.skippedConversions[targetItemString..sourceItemString] = true
private.data[targetItemString][sourceItemString] = nil
end
if private.skippedConversions[targetItemString..sourceItemString] then
return
end
private.data[targetItemString][sourceItemString] = {
method = method,
rate = rate,
amount = amount,
minAmount = minAmount,
maxAmount = maxAmount,
}
ItemInfo.FetchInfo(targetItemString)
ItemInfo.FetchInfo(sourceItemString)
wipe(private.sourceItemCache)
end
function private.GetSourceItemsHelper(targetItemString, result, depthLookup, currentDepth, currentRate)
if currentDepth >= MAX_CONVERSION_DEPTH or not private.data[targetItemString] then
return
end
for sourceItemString, info in pairs(private.data[targetItemString]) do
if not result[sourceItemString] or depthLookup[sourceItemString] > currentDepth then
local rate = info.rate * currentRate
result[sourceItemString] = rate
depthLookup[sourceItemString] = currentDepth
private.GetSourceItemsHelper(sourceItemString, result, depthLookup, currentDepth + 1, rate)
end
end
end
function private.TargetItemsByMethodIteratorHelper(context, index)
while true do
index = next(private.data, index)
local items = private.data[index]
if not items then
TempTable.Release(context)
return
end
local info = items[context.sourceItemString]
if info and ((not context.method and info.method ~= Conversions.METHOD.CRAFT) or info.method == context.method) then
return index, info.rate, info.amount, info.minAmount, info.maxAmount
end
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,769 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
-- TSM's error handler
local _, TSM = ...
local ErrorHandler = TSM.Init("Service.ErrorHandler")
local Log = TSM.Include("Util.Log")
local String = TSM.Include("Util.String")
local Event = TSM.Include("Util.Event")
local JSON = TSM.Include("Util.JSON")
local TempTable = TSM.Include("Util.TempTable")
local L = TSM.Include("Locale").GetTable()
local private = {
origErrorHandler = nil,
errorFrame = nil,
isSilent = nil,
errorSuppressed = nil,
errorReports = {},
num = 0,
localLinesTemp = {},
hitInternalError = false,
isManual = nil,
ignoreErrors = false,
globalNameTranslation = {},
}
local MAX_ERROR_REPORT_AGE = 7 * 24 * 60 * 60 -- 1 week
local MAX_STACK_DEPTH = 50
local ADDON_SUITES = {
"ArkInventory",
"AtlasLoot",
"Altoholic",
"Auc-",
"Bagnon",
"BigWigs",
"Broker",
"ButtonFacade",
"Carbonite",
"DataStore",
"DBM",
"Dominos",
"DXE",
"EveryQuest",
"Forte",
"FuBar",
"GatherMate2",
"Grid",
"LightHeaded",
"LittleWigs",
"Masque",
"MogIt",
"Odyssey",
"Overachiever",
"PitBull4",
"Prat-3.0",
"RaidAchievement",
"Skada",
"SpellFlash",
"TidyPlates",
"TipTac",
"Titan",
"UnderHood",
"WowPro",
"ZOMGBuffs",
}
local OLD_TSM_MODULES = {
"TradeSkillMaster_Accounting",
"TradeSkillMaster_AuctionDB",
"TradeSkillMaster_Auctioning",
"TradeSkillMaster_Crafting",
"TradeSkillMaster_Destroying",
"TradeSkillMaster_Mailing",
"TradeSkillMaster_Shopping",
"TradeSkillMaster_Vendoring",
"TradeSkillMaster_Warehousing",
}
local PRINT_PREFIX = "|cffff0000TSM:|r "
-- ============================================================================
-- Module Functions
-- ============================================================================
function ErrorHandler.ShowForThread(err, thread)
local stackLine = debugstack(thread, 0, 1, 0)
local oldModule = strmatch(stackLine, "(lMaster_[A-Za-z]+)")
if oldModule and tContains(OLD_TSM_MODULES, "TradeSkil"..oldModule) then
-- ignore errors from old modules
return
end
-- show an error, but don't cause an exception to be thrown
private.isSilent = true
private.ErrorHandler(err, thread)
end
function ErrorHandler.ShowManual()
private.isManual = true
-- show an error, but don't cause an exception to be thrown
private.isSilent = true
private.ErrorHandler("Manually triggered error")
end
function ErrorHandler.SaveReports(appDB)
if private.errorFrame then
private.errorFrame:Hide()
end
appDB.errorReports = appDB.errorReports or { updateTime = 0, data = {} }
if #private.errorReports > 0 then
appDB.errorReports.updateTime = private.errorReports[#private.errorReports].timestamp
end
-- remove any events which are too old
for i = #appDB.errorReports.data, 1, -1 do
local timestamp = strmatch(appDB.errorReports.data[i], "([0-9]+)%]$") or ""
if (tonumber(timestamp) or 0) < time() - MAX_ERROR_REPORT_AGE then
tremove(appDB.errorReports.data, i)
end
end
for _, report in ipairs(private.errorReports) do
local line = format("[%s,\"%s\",%d]", JSON.Encode(report.errorInfo), report.details, report.timestamp)
tinsert(appDB.errorReports.data, line)
end
end
-- ============================================================================
-- Error Handler
-- ============================================================================
function private.ErrorHandler(msg, thread)
-- ignore errors while we are handling this error
private.ignoreErrors = true
local isSilent = private.isSilent
private.isSilent = nil
local isManual = private.isManual
private.isManual = nil
private.CreateErrorFrame()
if type(thread) ~= "thread" then
thread = nil
end
if private.errorFrame:IsVisible() and private.errorSuppressed then
-- already showing an error and suppressed another one, so silently ignore this one
private.ignoreErrors = false
return true
end
-- shorten the paths in the error message
msg = gsub(msg, "%.%.%.T?r?a?d?e?S?k?i?l?l?M?a?ster([_A-Za-z]*)\\", "TradeSkillMaster%1\\")
msg = strsub(msg, strfind(msg, "TradeSkillMaster") or 1)
msg = gsub(msg, "TradeSkillMaster([^%.])", "TSM%1")
-- build our global name translation table
wipe(private.globalNameTranslation)
pcall(function()
local UIElements = TSM.Include("UI.UIElements")
local temp = {}
UIElements.GetDebugNameTranslation(temp)
for k, v in pairs(temp) do
private.globalNameTranslation[String.Escape(k)] = v
end
end)
-- build stack trace with locals and get addon name
local stackInfo = private.GetStackInfo(msg, thread)
local addonName = isSilent and "TradeSkillMaster" or nil
for _, info in ipairs(stackInfo) do
if not addonName then
addonName = strmatch(info.file, "[A-Za-z]+%.lua") and private.IsTSMAddon(info.file) or nil
end
end
if not isManual and addonName ~= "TradeSkillMaster" then
-- not a TSM error
private.ignoreErrors = false
return false
end
if not TSM.IsDevVersion() and not isManual then
-- log the error (use a format string in case there are '%' characters in the msg)
Log.Err("%s", msg)
end
if private.errorFrame:IsVisible() then
-- already showing an error, so suppress this one and return
private.errorSuppressed = true
print(PRINT_PREFIX..L["Additional error suppressed"])
return true
end
private.num = private.num + 1
local clientVersion, clientBuild = GetBuildInfo()
local errorInfo = {
msg = #stackInfo > 0 and gsub(msg, String.Escape(stackInfo[1].file)..":"..stackInfo[1].line..": ", "") or msg,
stackInfo = stackInfo,
time = time(),
debugTime = floor(debugprofilestop()),
client = format("%s (%s)", clientVersion, clientBuild),
locale = GetLocale(),
inCombat = tostring(InCombatLockdown() and true or false),
version = TSM.GetVersion(),
}
-- temp table info
local tempTableLines = {}
for _, info in ipairs(TempTable.GetDebugInfo()) do
tinsert(tempTableLines, info)
end
errorInfo.tempTableStr = table.concat(tempTableLines, "\n")
-- object pool info
local status, objectPoolInfo = pcall(function() return TSM.Include("Util.ObjectPool").GetDebugInfo() end)
local objectPoolLines = {}
if status then
for name, objectInfo in pairs(objectPoolInfo) do
tinsert(objectPoolLines, format("%s (%d created, %d in use)", name, objectInfo.numCreated, objectInfo.numInUse))
for _, info in ipairs(objectInfo.info) do
tinsert(objectPoolLines, " "..info)
end
end
end
errorInfo.objectPoolStr = status and table.concat(objectPoolLines, "\n") or tostring(objectPoolInfo)
-- TSM thread info
local threadInfoStr = nil
status, threadInfoStr = pcall(function() return TSM.Include("Service.Threading").GetDebugStr() end)
errorInfo.threadInfoStr = tostring(threadInfoStr)
-- recent debug log entries
local entries = {}
for i = Log.Length(), 1, -1 do
local severity, location, timeStr, logMsg = Log.Get(i)
tinsert(entries, format("%s [%s] {%s} %s", timeStr, severity, location, logMsg))
end
errorInfo.debugLogStr = table.concat(entries, "\n")
-- addons
local hasAddonSuite = {}
local addonsLines = {}
for i = 1, GetNumAddOns() do
local name, _, _, loadable = GetAddOnInfo(i)
if loadable then
local version = strtrim(GetAddOnMetadata(name, "X-Curse-Packaged-Version") or GetAddOnMetadata(name, "Version") or "")
local loaded = IsAddOnLoaded(i)
local isSuite = nil
for _, commonTerm in ipairs(ADDON_SUITES) do
if strsub(name, 1, #commonTerm) == commonTerm then
isSuite = commonTerm
break
end
end
local commonTerm = "TradeSkillMaster"
if isSuite then
if not hasAddonSuite[isSuite] then
tinsert(addonsLines, name.." ("..version..")"..(loaded and "" or " [Not Loaded]"))
hasAddonSuite[isSuite] = true
end
elseif strsub(name, 1, #commonTerm) == commonTerm then
name = gsub(name, "TradeSkillMaster", "TSM")
tinsert(addonsLines, name.." ("..version..")"..(loaded and "" or " [Not Loaded]"))
else
tinsert(addonsLines, name.." ("..version..")"..(loaded and "" or " [Not Loaded]"))
end
end
end
errorInfo.addonsStr = table.concat(addonsLines, "\n")
-- show this error
local stackInfoLines = {}
for _, info in ipairs(errorInfo.stackInfo) do
local localsStr = info.locals ~= "" and ("\n |cffaaaaaa"..gsub(info.locals, "\n", "\n ").."|r") or ""
local locationStr = info.line ~= 0 and strjoin(":", info.file, info.line) or info.file
tinsert(stackInfoLines, locationStr.." <"..info.func..">"..localsStr)
end
private.errorFrame.errorStr = strjoin("\n",
private.FormatErrorMessageSection("Message", msg),
private.FormatErrorMessageSection("Time", date("%m/%d/%y %H:%M:%S", errorInfo.time).." ("..floor(errorInfo.debugTime)..")"),
private.FormatErrorMessageSection("Client", errorInfo.client),
private.FormatErrorMessageSection("Locale", errorInfo.locale),
private.FormatErrorMessageSection("Combat", errorInfo.inCombat),
private.FormatErrorMessageSection("Error Count", private.num),
private.FormatErrorMessageSection("Stack Trace", table.concat(stackInfoLines, "\n"), true),
private.FormatErrorMessageSection("Temp Tables", errorInfo.tempTableStr, true),
private.FormatErrorMessageSection("Object Pools", errorInfo.objectPoolStr, true),
private.FormatErrorMessageSection("Running Threads", errorInfo.threadInfoStr, true),
private.FormatErrorMessageSection("Debug Log", errorInfo.debugLogStr, true),
private.FormatErrorMessageSection("Addons", errorInfo.addonsStr, true)
)
-- remove unprintable characters
private.errorFrame.errorStr = gsub(private.errorFrame.errorStr, "[%z\001-\008\011-\031]", "?")
private.errorFrame.errorInfo = errorInfo
private.errorFrame.isManual = isManual
private.errorFrame:Show()
print(PRINT_PREFIX..L["Looks like TradeSkillMaster has encountered an error. Please help the author fix this error by following the instructions shown."])
if TSM.__IS_TEST_ENV then
print(private.errorFrame.errorStr)
end
private.ignoreErrors = false
return true
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetStackInfo(msg, thread)
local errLocation = strmatch(msg, "[A-Za-z]+%.lua:[0-9]+")
local stackInfo = {}
local stackStarted = false
for i = 0, MAX_STACK_DEPTH do
local prevStackFunc = #stackInfo > 0 and stackInfo[#stackInfo].func or nil
local file, line, func, localsStr, newPrevStackFunc = private.GetStackLevelInfo(i, thread, prevStackFunc)
if newPrevStackFunc then
stackInfo[#stackInfo].func = newPrevStackFunc
end
if file then
if not stackStarted then
if errLocation then
stackStarted = strmatch(file..":"..line, "[A-Za-z]+%.lua:[0-9]+") == errLocation
else
stackStarted = i > (thread and 1 or 4) and file ~= "[C]"
end
end
if stackStarted then
tinsert(stackInfo, {
file = file,
line = line,
func = func,
locals = localsStr,
})
end
end
end
return stackInfo
end
function private.GetStackLevelInfo(level, thread, prevStackFunc)
local stackLine = nil
if thread then
stackLine = debugstack(thread, level, 1, 0)
else
level = level + 1
stackLine = debugstack(level, 1, 0)
end
stackLine = gsub(stackLine, "^%[string \"@([^%.]+%.lua)\"%]", "%1")
local locals = debuglocals(level)
stackLine = gsub(stackLine, "%.%.%.T?r?a?d?e?S?k?i?l?l?M?a?ster([_A-Za-z]*)\\", "TradeSkillMaster%1\\")
stackLine = gsub(stackLine, "%.%.%.", "")
stackLine = gsub(stackLine, "`", "<", 1)
stackLine = gsub(stackLine, "'", ">", 1)
stackLine = strtrim(stackLine)
if stackLine == "" then
return
end
-- Parse out the file, line, and function name
local locationStr, functionStr = strmatch(stackLine, "^(.-): in function (<[^\n]*>)")
if not locationStr then
locationStr, functionStr = strmatch(stackLine, "^(.-): in (main chunk)")
end
if not locationStr then
return
end
locationStr = strsub(locationStr, strfind(locationStr, "TradeSkillMaster") or 1)
locationStr = gsub(locationStr, "TradeSkillMaster([^%.])", "TSM%1")
functionStr = functionStr and gsub(gsub(functionStr, ".*\\", ""), "[<>]", "") or ""
local file, line = strmatch(locationStr, "^(.+):(%d+)$")
file = file or locationStr
line = tonumber(line) or 0
local func = strsub(functionStr, strfind(functionStr, "`") and 2 or 1, -1) or "?"
func = func ~= "" and func or "?"
if strfind(locationStr, "LibTSMClass%.lua:") then
-- ignore stack frames from the class code's wrapper function
if func ~= "?" and prevStackFunc and not strmatch(func, "^.+:[0-9]+$") and strmatch(prevStackFunc, "^.+:[0-9]+$") then
-- this stack frame includes the class method we were accessing in the previous one, so go back and fix it up
local className = locals and strmatch(locals, "\n +str = \"([A-Za-z_0-9]+):[0-9A-F]+\"\n") or "?"
prevStackFunc = className.."."..func
end
return nil, nil, nil, nil, prevStackFunc
end
-- add locals for addon functions (debuglocals() doesn't always work - or ever for threads)
local localsStr = locals and private.ParseLocals(locals, file) or ""
return file, line, func, localsStr, nil
end
function private.ParseLocals(locals, file)
if strmatch(file, "^%[") then
return
end
local fileName = strmatch(file, "([A-Za-z%-_0-9]+)%.lua")
local isBlizzardFile = strmatch(file, "Interface\\FrameXML\\")
local isPrivateTable, isLocaleTable, isPackageTable, isSelfTable = false, false, false, false
wipe(private.localLinesTemp)
locals = gsub(locals, "<([a-z]+)> {[\n\t ]+}", "<%1> {}")
locals = gsub(locals, " = <function> defined @", "@")
locals = gsub(locals, "<table> {", "{")
for localLine in gmatch(locals, "[^\n]+") do
local shouldIgnoreLine = false
if strmatch(localLine, "^ *%(") then
shouldIgnoreLine = true
elseif strmatch(localLine, "LibTSMClass%.lua:") then
-- ignore class methods
shouldIgnoreLine = true
elseif strmatch(localLine, "<unnamed> {}$") then
-- ignore internal WoW frame members
shouldIgnoreLine = true
end
if not shouldIgnoreLine then
local level = #strmatch(localLine, "^ *")
localLine = strrep(" ", level)..strtrim(localLine)
localLine = gsub(localLine, "Interface\\[Aa]dd[Oo]ns\\TradeSkillMaster", "TSM")
localLine = gsub(localLine, "\124", "\\124")
for matchStr, replaceStr in pairs(private.globalNameTranslation) do
localLine = gsub(localLine, matchStr, replaceStr)
end
if level > 0 then
if isBlizzardFile then
-- for Blizzard stack frames, only include level 0 locals
shouldIgnoreLine = true
elseif strmatch(localLine, "^ *[_]*[A-Z].+@TSM") then
-- ignore table methods (based on their name being UpperCamelCase - potentially with leading underscores)
shouldIgnoreLine = true
elseif isLocaleTable then
-- ignore everything within the locale table
shouldIgnoreLine = true
elseif isPackageTable then
-- ignore the package table completely
shouldIgnoreLine = true
elseif (isSelfTable or isPrivateTable) and strmatch(localLine, "^ *[_a-zA-Z0-9]+ = {}") then
-- ignore empty tables within objects or the private table
shouldIgnoreLine = true
elseif strmatch(localLine, "^%s+0 = <userdata>$") then
-- remove userdata table entries
shouldIgnoreLine = true
end
end
if not shouldIgnoreLine then
tinsert(private.localLinesTemp, localLine)
end
if level == 0 then
isPackageTable = strmatch(localLine, "%s*"..fileName.." = {") and true or false
isPrivateTable = strmatch(localLine, "%s*private = {") and true or false
isLocaleTable = strmatch(localLine, "%s*L = {") and true or false
isSelfTable = strmatch(localLine, "%s*self = {") and true or false
end
end
end
-- remove any top-level empty tables
local i = #private.localLinesTemp
while i > 0 do
if i > 1 and private.localLinesTemp[i] == "}" and strmatch(private.localLinesTemp[i - 1], "^[A-Za-z_].* = {$") then
tremove(private.localLinesTemp, i)
tremove(private.localLinesTemp, i - 1)
i = i - 2
elseif strmatch(private.localLinesTemp[i], "^[A-Za-z_].* = {}$") then
tremove(private.localLinesTemp, i)
i = i - 1
else
i = i - 1
end
end
return #private.localLinesTemp > 0 and table.concat(private.localLinesTemp, "\n") or nil
end
function private.IsTSMAddon(str)
if strfind(str, "Auc-Adcanced\\CoreScan.lua") then
-- ignore auctioneer errors
return nil
elseif strfind(str, "Master\\External\\") then
-- ignore errors from libraries
return nil
elseif strfind(str, "Master\\Core\\API.lua") then
-- ignore errors from public APIs
return nil
elseif strfind(str, "Master_AppHelper\\") then
return "TradeSkillMaster_AppHelper"
elseif strfind(str, "lMaster\\") then
return "TradeSkillMaster"
elseif strfind(str, "ster\\Core\\UI\\") then
return "TradeSkillMaster"
elseif strfind(str, "r\\LibTSM\\") then
return "TradeSkillMaster"
elseif strfind(str, "^TSM\\") then
return "TradeSkillMaster"
end
return nil
end
function private.AddonBlockedHandler(event, addonName, addonFunc)
if not strmatch(addonName, "TradeSkillMaster") then
return
end
-- just log it - it might not be TSM that cause the taint
Log.Err("[%s] AddOn '%s' tried to call the protected function '%s'.", event, addonName or "<name>", addonFunc or "<func>")
end
function private.SanitizeString(str)
str = gsub(str, "\124cff[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]([^\124]+)\124r", "%1")
str = gsub(str, "[\\]+", "/")
str = gsub(str, "\"", "'")
return str
end
function private.FormatErrorMessageSection(heading, info, isMultiLine)
-- replace unprintable characters with "?"
info = gsub(info, "[^\t\n -~]", "?")
local prefix = nil
if isMultiLine then
prefix = info ~= "" and "\n " or ""
info = gsub(info, "\n", "\n ")
else
prefix = info ~= "" and " " or ""
end
return "|cff99ffff"..heading..":|r"..prefix..info
end
-- ============================================================================
-- Error Frame
-- ============================================================================
function private.CreateErrorFrame()
if private.errorFrame then
return
end
local STEPS_TEXT = "Steps leading up to the error:\n1) List\n2) Steps\n3) Here"
local frame = CreateFrame("Frame", nil, UIParent, TSM.IsShadowlands() and "BackdropTemplate" or nil)
private.errorFrame = frame
frame:Hide()
frame:SetWidth(500)
frame:SetHeight(400)
frame:SetFrameStrata("FULLSCREEN_DIALOG")
frame:SetPoint("RIGHT", -100, 0)
frame:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8X8",
edgeFile = "Interface\\Buttons\\WHITE8X8",
edgeSize = 2,
})
frame:SetBackdropColor(0, 0, 0, 1)
frame:SetBackdropBorderColor(0.3, 0.3, 0.3, 1)
frame:SetScript("OnShow", function(self)
self.showingError = self.isManual or TSM.IsDevVersion()
self.details = STEPS_TEXT
if self.showingError then
-- this is a dev version so show the error (only)
self.text:SetText("Looks like TradeSkillMaster has encountered an error.")
self.switchBtn:SetText("Hide Error")
self.editBox:SetText(self.errorStr)
else
self.text:SetText("Looks like TradeSkillMaster has encountered an error. Please provide the steps which lead to this error to help the TSM team fix it, then click either button at the bottom of the window to automatically report this error.")
self.switchBtn:SetText("Show Error")
self.editBox:SetText(self.details)
end
end)
frame:SetScript("OnHide", function()
local details = private.errorFrame.showingError and private.errorFrame.details or private.errorFrame.editBox:GetText()
local changedDetails = details ~= STEPS_TEXT
if (not TSM.IsDevVersion() and not private.errorFrame.isManual and (changedDetails or private.num == 1)) or IsShiftKeyDown() then
tinsert(private.errorReports, {
errorInfo = private.errorFrame.errorInfo,
details = private.SanitizeString(details),
timestamp = time(),
})
end
private.errorSuppressed = nil
end)
local title = frame:CreateFontString()
title:SetHeight(20)
title:SetPoint("TOPLEFT", 0, -10)
title:SetPoint("TOPRIGHT", 0, -10)
title:SetFontObject(GameFontNormalLarge)
title:SetTextColor(1, 1, 1, 1)
title:SetJustifyH("CENTER")
title:SetJustifyV("MIDDLE")
local status, versionText = pcall(TSM.GetVersion)
versionText = status and versionText or "?"
title:SetText("TSM Error Window ("..versionText..")")
local hLine = frame:CreateTexture(nil, "ARTWORK")
hLine:SetHeight(2)
hLine:SetColorTexture(0.3, 0.3, 0.3, 1)
hLine:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -10)
hLine:SetPoint("TOPRIGHT", title, "BOTTOMRIGHT", 0, -10)
local text = frame:CreateFontString()
frame.text = text
text:SetHeight(45)
text:SetPoint("TOPLEFT", hLine, "BOTTOMLEFT", 8, -8)
text:SetPoint("TOPRIGHT", hLine, "BOTTOMRIGHT", -8, -8)
text:SetFontObject(GameFontNormal)
text:SetTextColor(1, 1, 1, 1)
text:SetJustifyH("LEFT")
text:SetJustifyV("MIDDLE")
local switchBtn = CreateFrame("Button", nil, frame)
frame.switchBtn = switchBtn
switchBtn:SetPoint("TOPRIGHT", -4, -10)
switchBtn:SetWidth(100)
switchBtn:SetHeight(20)
local fontString = switchBtn:CreateFontString()
fontString:SetFontObject(GameFontNormalSmall)
fontString:SetJustifyH("CENTER")
fontString:SetJustifyV("MIDDLE")
switchBtn:SetFontString(fontString)
switchBtn:SetScript("OnClick", function(self)
private.errorFrame.showingError = not private.errorFrame.showingError
if private.errorFrame.showingError then
private.errorFrame.details = private.errorFrame.editBox:GetText()
self:SetText("Hide Error")
private.errorFrame.editBox:SetText(private.errorFrame.errorStr)
else
self:SetText("Show Error")
private.errorFrame.editBox:SetText(private.errorFrame.details)
end
end)
local hLine2 = frame:CreateTexture(nil, "ARTWORK")
hLine2:SetHeight(2)
hLine2:SetColorTexture(0.3, 0.3, 0.3, 1)
hLine2:SetPoint("TOPLEFT", text, "BOTTOMLEFT", -8, -4)
hLine2:SetPoint("TOPRIGHT", text, "BOTTOMRIGHT", 8, -4)
local scrollFrame = CreateFrame("ScrollFrame", nil, frame, "UIPanelScrollFrameTemplate")
scrollFrame:SetPoint("TOPLEFT", hLine2, "BOTTOMLEFT", 8, -4)
scrollFrame:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -26, 38)
local editBox = CreateFrame("EditBox", nil, scrollFrame)
frame.editBox = editBox
editBox:SetWidth(scrollFrame:GetWidth())
editBox:SetFontObject(ChatFontNormal)
editBox:SetMultiLine(true)
editBox:SetAutoFocus(false)
editBox:SetMaxLetters(0)
editBox:SetTextColor(1, 1, 1, 1)
editBox:SetScript("OnUpdate", function(self)
local offset = scrollFrame:GetVerticalScroll()
self:SetHitRectInsets(0, 0, offset, self:GetHeight() - offset - scrollFrame:GetHeight())
end)
editBox:SetScript("OnEditFocusGained", function(self)
self:HighlightText()
end)
editBox:SetScript("OnCursorChanged", function(self)
if private.errorFrame.showingError and self:HasFocus() then
self:HighlightText()
end
end)
editBox:SetScript("OnEscapePressed", function(self)
if private.errorFrame.showingError then
self:HighlightText(0, 0)
end
self:ClearFocus()
end)
scrollFrame:SetScrollChild(editBox)
local hLine3 = frame:CreateTexture(nil, "ARTWORK")
hLine3:SetHeight(2)
hLine3:SetColorTexture(0.3, 0.3, 0.3, 1)
hLine3:SetPoint("BOTTOMLEFT", frame, 0, 35)
hLine3:SetPoint("BOTTOMRIGHT", frame, 0, 35)
local reloadBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
frame.reloadBtn = reloadBtn
reloadBtn:SetPoint("BOTTOMLEFT", 4, 4)
reloadBtn:SetWidth(120)
reloadBtn:SetHeight(30)
reloadBtn:SetText(RELOADUI)
reloadBtn:SetScript("OnClick", function()
frame:Hide()
ReloadUI()
end)
local closeBtn = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
frame.closeBtn = closeBtn
closeBtn:SetPoint("BOTTOMRIGHT", -4, 4)
closeBtn:SetWidth(120)
closeBtn:SetHeight(30)
closeBtn:SetText(DONE)
closeBtn:SetScript("OnClick", function()
frame:Hide()
end)
local stepsText = frame:CreateFontString()
frame.stepsText = stepsText
stepsText:SetWidth(200)
stepsText:SetHeight(30)
stepsText:SetPoint("BOTTOM", 0, 4)
stepsText:SetFontObject(GameFontNormal)
stepsText:SetTextColor(1, 0, 0, 1)
stepsText:SetJustifyH("CENTER")
stepsText:SetJustifyV("MIDDLE")
stepsText:SetText("Please enter steps before submitting")
end
-- ============================================================================
-- Register Error Handler
-- ============================================================================
do
private.origErrorHandler = geterrorhandler()
local function ErrorHandlerFunc(errMsg, isBugGrabber)
local tsmErrMsg = strtrim(tostring(errMsg))
if private.ignoreErrors then
-- we're ignoring errors
tsmErrMsg = nil
elseif strmatch(tsmErrMsg, "auc%-stat%-wowuction") or strmatch(tsmErrMsg, "TheUndermineJournal%.lua") or strmatch(tsmErrMsg, "\\SavedVariables\\TradeSkillMaster") or strmatch(tsmErrMsg, "AddOn TradeSkillMaster[_a-zA-Z]* attempted") or (strmatch(tsmErrMsg, "ItemTooltipClasses\\Wrapper%.lua:98") and strmatch(tsmErrMsg, "SetQuest")) then
-- explicitly ignore these errors
tsmErrMsg = nil
end
if tsmErrMsg then
-- look at the stack trace to see if this is a TSM error
for i = 2, MAX_STACK_DEPTH do
local stackLine = debugstack(i, 1, 0)
local oldModule = strmatch(stackLine, "(lMaster_[A-Za-z]+)")
if oldModule and tContains(OLD_TSM_MODULES, "TradeSkil"..oldModule) then
-- ignore errors from old modules
return
end
if not strmatch(stackLine, "^%[C%]:") and not strmatch(stackLine, "%(tail call%):") and not strmatch(stackLine, "^%[string \"[^@]") and not strmatch(stackLine, "lMaster\\External\\[A-Za-z0-9%-_%.]+\\") and not strmatch(stackLine, "SharedXML") and not strmatch(stackLine, "CallbackHandler") and not strmatch(stackLine, "!BugGrabber") and not strmatch(stackLine, "ErrorHandler%.lua") then
if not private.IsTSMAddon(stackLine) then
tsmErrMsg = nil
end
break
end
end
end
if tsmErrMsg then
local status, ret = pcall(private.ErrorHandler, tsmErrMsg)
if status and ret then
return ret
elseif not status and not private.hitInternalError then
private.hitInternalError = true
print("Internal TSM error: "..tostring(ret))
end
end
local oldModule = strmatch(errMsg, "(lMaster_[A-Za-z]+)")
if oldModule and tContains(OLD_TSM_MODULES, "TradeSkil"..oldModule) then
-- ignore errors from old modules
return
end
if not isBugGrabber then
return private.origErrorHandler and private.origErrorHandler(errMsg) or nil
end
end
seterrorhandler(ErrorHandlerFunc)
if BugGrabber and BugGrabber.RegisterCallback then
BugGrabber.RegisterCallback({}, "BugGrabber_BugGrabbed", function(_, errObj)
ErrorHandlerFunc(errObj.message, true)
end)
end
Event.Register("ADDON_ACTION_FORBIDDEN", private.AddonBlockedHandler)
Event.Register("ADDON_ACTION_BLOCKED", private.AddonBlockedHandler)
end

View File

@@ -0,0 +1,272 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local GuildTracking = TSM.Init("Service.GuildTracking")
local Database = TSM.Include("Util.Database")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local TempTable = TSM.Include("Util.TempTable")
local SlotId = TSM.Include("Util.SlotId")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local Settings = TSM.Include("Service.Settings")
local private = {
settings = nil,
slotDB = nil,
quantityDB = nil,
isOpen = nil,
pendingPetSlotIds = {},
}
local PLAYER_NAME = UnitName("player")
local PLAYER_GUILD = nil
local MAX_PET_SCANS = 10
-- don't use MAX_GUILDBANK_SLOTS_PER_TAB since it isn't available right away
local GUILD_BANK_TAB_SLOTS = 98
-- ============================================================================
-- Module Loading
-- ============================================================================
GuildTracking:OnSettingsLoad(function()
private.settings = Settings.NewView()
:AddKey("factionrealm", "internalData", "characterGuilds")
:AddKey("factionrealm", "internalData", "guildVaults")
private.slotDB = Database.NewSchema("GUILD_TRACKING_SLOTS")
:AddUniqueNumberField("slotId")
:AddNumberField("tab")
:AddNumberField("slot")
:AddStringField("itemString")
:AddSmartMapField("baseItemString", ItemString.GetBaseMap(), "itemString")
:AddNumberField("quantity")
:AddIndex("slotId")
:AddIndex("itemString")
:Commit()
private.quantityDB = Database.NewSchema("GUILD_TRACKING_QUANTITY")
:AddUniqueStringField("itemString")
:AddNumberField("quantity")
:Commit()
if not TSM.IsWowClassic() then
Event.Register("GUILDBANKFRAME_OPENED", private.GuildBankFrameOpenedHandler)
Event.Register("GUILDBANKFRAME_CLOSED", private.GuildBankFrameClosedHandler)
Event.Register("GUILDBANKBAGSLOTS_CHANGED", private.GuildBankBagSlotsChangedHandler)
Delay.AfterFrame(1, private.GetGuildName)
Event.Register("PLAYER_GUILD_UPDATE", private.GetGuildName)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function GuildTracking.BaseItemIterator()
return private.quantityDB:NewQuery()
:Select("itemString")
:IteratorAndRelease()
end
function GuildTracking.CreateQuery()
return private.slotDB:NewQuery()
end
function GuildTracking.CreateQueryItem(itemString)
local query = GuildTracking.CreateQuery()
if itemString == ItemString.GetBaseFast(itemString) then
query:Equal("baseItemString", itemString)
else
query:Equal("itemString", itemString)
end
return query
end
function GuildTracking.GetQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "quantity") or 0
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetGuildName()
if not IsInGuild() then
private.settings.characterGuilds[PLAYER_NAME] = nil
return
end
PLAYER_GUILD = GetGuildInfo("player")
if not PLAYER_GUILD then
-- try again next frame
Delay.AfterFrame(1, private.GetGuildName)
return
end
private.settings.characterGuilds[PLAYER_NAME] = PLAYER_GUILD
-- clean up any guilds with no players in them
local validGuilds = TempTable.Acquire()
for _, character in Settings.CharacterByAccountFactionrealmIterator() do
local guild = private.settings.characterGuilds[character]
if guild then
validGuilds[guild] = true
end
end
for character, guild in pairs(private.settings.characterGuilds) do
if not validGuilds[guild] then
private.settings.characterGuilds[character] = nil
end
end
for guild in pairs(private.settings.guildVaults) do
if not validGuilds[guild] then
private.settings.guildVaults[guild] = nil
end
end
TempTable.Release(validGuilds)
private.settings.guildVaults[PLAYER_GUILD] = private.settings.guildVaults[PLAYER_GUILD] or {}
for itemString, quantity in pairs(private.settings.guildVaults[PLAYER_GUILD]) do
if quantity <= 0 or itemString ~= ItemString.GetBase(itemString) then
private.settings.guildVaults[PLAYER_GUILD][itemString] = nil
end
end
private.RebuildQuantityDB()
end
function private.RebuildQuantityDB()
private.quantityDB:TruncateAndBulkInsertStart()
for itemString, quantity in pairs(private.settings.guildVaults[PLAYER_GUILD]) do
if quantity > 0 then
private.quantityDB:BulkInsertNewRow(itemString, quantity)
else
private.settings.guildVaults[PLAYER_GUILD][itemString] = nil
end
end
private.quantityDB:BulkInsertEnd()
end
function private.GuildBankFrameOpenedHandler()
local initialTab = GetCurrentGuildBankTab()
for i = 1, GetNumGuildBankTabs() do
QueryGuildBankTab(i)
end
QueryGuildBankTab(initialTab)
private.isOpen = true
end
function private.GuildBankFrameClosedHandler()
private.isOpen = nil
end
function private.GuildBankBagSlotsChangedHandler()
Delay.AfterFrame("guildBankScan", 2, private.GuildBankChangedDelayed)
end
function private.GuildBankChangedDelayed()
if not private.isOpen then
return
end
if not PLAYER_GUILD then
-- we don't have the guild name yet, so try again after a short delay
Delay.AfterFrame("guildBankScan", 2, private.GuildBankChangedDelayed)
return
end
private.ScanGuildBank()
end
function private.ScanGuildBank()
wipe(private.settings.guildVaults[PLAYER_GUILD])
wipe(private.pendingPetSlotIds)
private.slotDB:TruncateAndBulkInsertStart()
local didFail = false
for tab = 1, GetNumGuildBankTabs() do
-- only scan tabs which we have at least enough withdrawals to withdraw every slot
local _, _, _, _, numWithdrawals = GetGuildBankTabInfo(tab)
if numWithdrawals == -1 or numWithdrawals >= GUILD_BANK_TAB_SLOTS then
for slot = 1, GUILD_BANK_TAB_SLOTS do
local itemLink = GetGuildBankItemLink(tab, slot)
if itemLink then
local slotId = SlotId.Join(tab, slot)
local baseItemString = ItemString.GetBase(itemLink)
if baseItemString == ItemString.GetPetCage() then
private.pendingPetSlotIds[slotId] = true
baseItemString = nil
end
if baseItemString then
local _, quantity = GetGuildBankItemInfo(tab, slot)
if quantity == 0 then
-- the info for this slot isn't fully loaded yet
Log.Err("Failed to scan guild bank slot (%d)", slotId)
didFail = true
break
end
private.settings.guildVaults[PLAYER_GUILD][baseItemString] = (private.settings.guildVaults[PLAYER_GUILD][baseItemString] or 0) + quantity
local itemString = ItemString.Get(itemLink)
private.slotDB:BulkInsertNewRow(slotId, tab, slot, itemString, quantity)
end
end
end
end
if didFail then
break
end
end
private.RebuildQuantityDB()
private.slotDB:BulkInsertEnd()
if didFail then
Delay.AfterFrame("guildBankScan", 2, private.GuildBankChangedDelayed)
elseif next(private.pendingPetSlotIds) then
Delay.AfterFrame("guildBankPetScan", 2, private.ScanPetsDeferred)
else
Delay.Cancel("guildBankPetScan")
end
end
function private.ScanPetsDeferred()
if not TSMScanTooltip then
CreateFrame("GameTooltip", "TSMScanTooltip", UIParent, "GameTooltipTemplate")
end
TSMScanTooltip:SetOwner(UIParent, "ANCHOR_NONE")
TSMScanTooltip:ClearLines()
local numPetSlotIdsScanned = 0
local toRemove = TempTable.Acquire()
private.slotDB:BulkInsertStart()
for slotId in pairs(private.pendingPetSlotIds) do
local tab, slot = SlotId.Split(slotId)
local speciesId, level, rarity = TSMScanTooltip:SetGuildBankItem(tab, slot)
if speciesId and level and rarity then
local itemString = "p:"..speciesId..":"..level..":"..rarity
if itemString then
tinsert(toRemove, slotId)
local _, quantity = GetGuildBankItemInfo(tab, slot)
local baseItemString = ItemString.GetBase(itemString)
private.settings.guildVaults[PLAYER_GUILD][baseItemString] = (private.settings.guildVaults[PLAYER_GUILD][baseItemString] or 0) + quantity
private.slotDB:BulkInsertNewRow(slotId, tab, slot, itemString, quantity)
end
end
-- throttle how many pet slots we scan per call (regardless of whether or not it was successful)
numPetSlotIdsScanned = numPetSlotIdsScanned + 1
if numPetSlotIdsScanned == MAX_PET_SCANS then
break
end
end
private.RebuildQuantityDB()
private.slotDB:BulkInsertEnd()
Log.Info("Scanned %d pet slots", numPetSlotIdsScanned)
for _, slotId in ipairs(toRemove) do
private.pendingPetSlotIds[slotId] = nil
end
TempTable.Release(toRemove)
if next(private.pendingPetSlotIds) then
-- there are more to scan
Delay.AfterFrame("guildBankPetScan", 2, private.ScanPetsDeferred)
end
end

View File

@@ -0,0 +1,149 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Inventory = TSM.Init("Service.Inventory")
local ItemString = TSM.Include("Util.ItemString")
local Settings = TSM.Include("Service.Settings")
local CustomPrice = TSM.Include("Service.CustomPrice")
local BagTracking = TSM.Include("Service.BagTracking")
local AuctionTracking = TSM.Include("Service.AuctionTracking")
local MailTracking = TSM.Include("Service.MailTracking")
local Sync = TSM.Include("Service.Sync")
local private = {
settings = nil,
callbacks = {},
}
local PLAYER_NAME = UnitName("player")
-- ============================================================================
-- Module Loading
-- ============================================================================
Inventory:OnSettingsLoad(function()
private.settings = Settings.NewView()
:AddKey("factionrealm", "internalData", "pendingMail")
:AddKey("factionrealm", "coreOptions", "ignoreGuilds")
:AddKey("factionrealm", "internalData", "guildVaults")
BagTracking.RegisterCallback(private.QuantityChangedCallback)
AuctionTracking.RegisterCallback(private.QuantityChangedCallback)
MailTracking.RegisterCallback(private.QuantityChangedCallback)
Sync.RegisterMirrorCallback(private.QuantityChangedCallback)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Inventory.RegisterCallback(callback)
tinsert(private.callbacks, callback)
end
function Inventory.GetBagQuantity(itemString, character, factionrealm)
return private.InventoryQuantityHelper(itemString, "bagQuantity", character, factionrealm)
end
function Inventory.GetBankQuantity(itemString, character, factionrealm)
return private.InventoryQuantityHelper(itemString, "bankQuantity", character, factionrealm)
end
function Inventory.GetReagentBankQuantity(itemString, character, factionrealm)
return private.InventoryQuantityHelper(itemString, "reagentBankQuantity", character, factionrealm)
end
function Inventory.GetAuctionQuantity(itemString, character, factionrealm)
return private.InventoryQuantityHelper(itemString, "auctionQuantity", character, factionrealm)
end
function Inventory.GetMailQuantity(itemString, character, factionrealm)
itemString = ItemString.GetBaseFast(itemString)
character = character or PLAYER_NAME
local pendingQuantity = itemString and private.settings.pendingMail[character] and private.settings.pendingMail[character][itemString] or 0
return private.InventoryQuantityHelper(itemString, "mailQuantity", character, factionrealm) + pendingQuantity
end
function Inventory.GetGuildQuantity(itemString, guild)
itemString = ItemString.GetBase(itemString)
if not itemString then
return 0
end
guild = guild or (IsInGuild() and GetGuildInfo("player") or nil)
if not guild or private.settings.ignoreGuilds[guild] then
return 0
end
return private.settings.guildVaults[guild] and private.settings.guildVaults[guild][itemString] or 0
end
function Inventory.GetPlayerTotals(itemString)
itemString = ItemString.GetBaseFast(itemString)
local numPlayer, numAlts, numAuctions, numAltAuctions = 0, 0, 0, 0
numPlayer = numPlayer + Inventory.GetBagQuantity(itemString)
numPlayer = numPlayer + Inventory.GetBankQuantity(itemString)
numPlayer = numPlayer + Inventory.GetReagentBankQuantity(itemString)
numPlayer = numPlayer + Inventory.GetMailQuantity(itemString)
numAuctions = numAuctions + Inventory.GetAuctionQuantity(itemString)
for _, factionrealm, character in Settings.ConnectedFactionrealmAltCharacterIterator() do
numAlts = numAlts + Inventory.GetBagQuantity(itemString, character, factionrealm)
numAlts = numAlts + Inventory.GetBankQuantity(itemString, character, factionrealm)
numAlts = numAlts + Inventory.GetReagentBankQuantity(itemString, character, factionrealm)
numAlts = numAlts + Inventory.GetMailQuantity(itemString, character, factionrealm)
local auctionQuantity = Inventory.GetAuctionQuantity(itemString, character, factionrealm)
numAltAuctions = numAltAuctions + auctionQuantity
numAuctions = numAuctions + auctionQuantity
end
return numPlayer, numAlts, numAuctions, numAltAuctions
end
function Inventory.GetGuildTotal(itemString)
itemString = ItemString.GetBaseFast(itemString)
if not itemString then
return 0
end
local numGuild = 0
for guild, data in pairs(private.settings.guildVaults) do
if not private.settings.ignoreGuilds[guild] then
numGuild = numGuild + (data[itemString] or 0)
end
end
return numGuild
end
function Inventory.GetTotalQuantity(itemString)
local numPlayer, numAlts, numAuctions = Inventory.GetPlayerTotals(itemString)
local numGuild = Inventory.GetGuildTotal(itemString)
return numPlayer + numAlts + numAuctions + numGuild
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.GetCharacterInventoryData(settingKey, character, factionrealm)
local scopeKey = character and Settings.GetSyncScopeKeyByCharacter(character, factionrealm) or nil
return Settings.Get("sync", scopeKey, "internalData", settingKey)
end
function private.InventoryQuantityHelper(itemString, settingKey, character, factionrealm)
itemString = ItemString.GetBase(itemString)
if not itemString then
return 0
end
local tbl = private.GetCharacterInventoryData(settingKey, character, factionrealm)
return tbl and tbl[itemString] or 0
end
function private.QuantityChangedCallback()
CustomPrice.OnSourceChange("NumInventory")
for _, callback in ipairs(private.callbacks) do
callback()
end
end

View File

@@ -0,0 +1,232 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local InventoryInfo = TSM.Init("Service.InventoryInfo")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Event = TSM.Include("Util.Event")
local SlotId = TSM.Include("Util.SlotId")
local ItemString = TSM.Include("Util.ItemString")
local Table = TSM.Include("Util.Table")
local private = {
slotIdLocked = {},
slotIdSoulboundCached = {},
slotIdIsBoP = {},
slotIdIsBoA = {},
}
-- ============================================================================
-- Module Loading
-- ============================================================================
InventoryInfo:OnModuleLoad(function()
Event.Register("ITEM_LOCKED", private.ItemLockedHandler)
Event.Register("ITEM_UNLOCKED", private.ItemUnlockedHandler)
Event.Register("BAG_UPDATE", private.BagUpdateHandler)
Event.Register("PLAYERBANKSLOTS_CHANGED", private.BankSlotChangedHandler)
if not TSM.IsWowClassic() then
Event.Register("PLAYERREAGENTBANKSLOTS_CHANGED", private.ReagentBankSlotChangedHandler)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Check if an item will go in a bag.
-- @tparam string link The item
-- @tparam number bag The bag index
-- @treturn boolean Whether or not the item will go in the bag
function InventoryInfo.ItemWillGoInBag(link, bag)
if not link or not bag then
return
end
if bag == BACKPACK_CONTAINER or bag == BANK_CONTAINER then
return true
elseif bag == REAGENTBANK_CONTAINER then
return IsReagentBankUnlocked() and ItemInfo.IsCraftingReagent(link)
end
local itemFamily = GetItemFamily(link) or 0
if ItemInfo.GetClassId(link) == LE_ITEM_CLASS_CONTAINER then
-- bags report their family as what can go inside them, not what they can go inside
itemFamily = 0
end
local _, bagFamily = GetContainerNumFreeSlots(bag)
if not bagFamily then
return
end
return bagFamily == 0 or bit.band(itemFamily, bagFamily) > 0
end
function InventoryInfo.IsBagSlotLocked(bag, slot)
return private.slotIdLocked[SlotId.Join(bag, slot)]
end
function InventoryInfo.IsSoulbound(bag, slot)
local slotId = SlotId.Join(bag, slot)
if private.slotIdSoulboundCached[slotId] then
return private.slotIdIsBoP[slotId], private.slotIdIsBoA[slotId]
end
if not TSMScanTooltip then
CreateFrame("GameTooltip", "TSMScanTooltip", UIParent, "GameTooltipTemplate")
end
TSMScanTooltip:SetOwner(UIParent, "ANCHOR_NONE")
TSMScanTooltip:ClearLines()
if GetContainerItemID(bag, slot) == ItemString.ToId(ItemString.GetPetCage()) then
-- battle pets are never BoP or BoA
private.slotIdSoulboundCached[slotId] = true
private.slotIdIsBoP[slotId] = false
private.slotIdIsBoA[slotId] = false
return false, false
end
-- set TSMScanTooltip to show the inventory item
if bag == BANK_CONTAINER then
TSMScanTooltip:SetInventoryItem("player", BankButtonIDToInvSlotID(slot))
elseif bag == REAGENTBANK_CONTAINER then
TSMScanTooltip:SetInventoryItem("player", ReagentBankButtonIDToInvSlotID(slot))
else
TSMScanTooltip:SetBagItem(bag, slot)
end
-- scan the tooltip
local numLines = TSMScanTooltip:NumLines()
if numLines < 1 then
-- the tooltip didn't fully load or there's nothing in this slot
return nil, nil
end
local isBOP, isBOA = false, false
for id = 2, numLines do
local text = private.GetTooltipText(_G["TSMScanTooltipTextLeft"..id])
if text then
if (text == ITEM_BIND_ON_PICKUP and id < 4) or text == ITEM_SOULBOUND or text == ITEM_BIND_QUEST then
isBOP = true
break
elseif (text == ITEM_ACCOUNTBOUND or text == ITEM_BIND_TO_ACCOUNT or text == ITEM_BIND_TO_BNETACCOUNT or text == ITEM_BNETACCOUNTBOUND) then
isBOA = true
break
end
end
end
private.slotIdSoulboundCached[slotId] = true
private.slotIdIsBoP[slotId] = isBOP
private.slotIdIsBoA[slotId] = isBOA
return isBOP, isBOA
end
function InventoryInfo.HasUsedCharges(bag, slot)
-- figure out if this item has a max number of charges
local itemId = GetContainerItemID(bag, slot)
if not itemId or itemId == ItemString.ToId(ItemString.GetPetCage()) then
return false
end
if not TSMScanTooltip then
CreateFrame("GameTooltip", "TSMScanTooltip", UIParent, "GameTooltipTemplate")
end
TSMScanTooltip:SetOwner(UIParent, "ANCHOR_NONE")
TSMScanTooltip:ClearLines()
TSMScanTooltip:SetItemByID(itemId)
local maxCharges = private.GetScanTooltipCharges()
if not maxCharges then
return false
end
-- set TSMScanTooltip to show the inventory item
if bag == BANK_CONTAINER then
TSMScanTooltip:SetInventoryItem("player", BankButtonIDToInvSlotID(slot))
elseif bag == REAGENTBANK_CONTAINER then
TSMScanTooltip:SetInventoryItem("player", ReagentBankButtonIDToInvSlotID(slot))
else
TSMScanTooltip:SetBagItem(bag, slot)
end
-- check if there are used charges
if maxCharges and private.GetScanTooltipCharges() ~= maxCharges then
return true
end
return false
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ItemLockedHandler(_, bag, slot)
if not slot then
return
end
private.slotIdLocked[SlotId.Join(bag, slot)] = true
end
function private.ItemUnlockedHandler(_, bag, slot)
if not slot then
return
end
private.slotIdLocked[SlotId.Join(bag, slot)] = nil
end
function private.BagUpdateHandler(_, bag)
-- clear the soulbound cache for everything in this bag
Table.Filter(private.slotIdSoulboundCached, private.SlotIdSoulboundCachedFilter, bag)
end
function private.BankSlotChangedHandler(_, slot)
if slot <= NUM_BANKGENERIC_SLOTS then
-- one of the slots of the primary bank container changed, so just clear the cache for this slot
private.slotIdSoulboundCached[SlotId.Join(BANK_CONTAINER, slot)] = nil
else
-- one of the extra bank bags changed, so clear the cache for the entire bag
Table.Filter(private.slotIdSoulboundCached, private.SlotIdSoulboundCachedFilter, slot - NUM_BANKGENERIC_SLOTS)
end
end
function private.SlotIdSoulboundCachedFilter(slotId, _, bag)
return SlotId.Split(slotId) == bag
end
function private.ReagentBankSlotChangedHandler(_, slot)
-- clear the soulbound cache for this slot
private.slotIdSoulboundCached[SlotId.Join(REAGENTBANK_CONTAINER, slot)] = nil
end
function private.GetTooltipText(text)
local textStr = strtrim(text and text:GetText() or "")
if textStr == "" then return end
local r, g, b = text:GetTextColor()
return textStr, floor(r * 256), floor(g * 256), floor(b * 256)
end
function private.GetScanTooltipCharges()
for id = 2, TSMScanTooltip:NumLines() do
local text = private.GetTooltipText(_G["TSMScanTooltipTextLeft"..id])
local num = text and strmatch(text, "%d+")
local chargesStr = gsub(ITEM_SPELL_CHARGES, "%%d", "%%d+")
if strfind(chargesStr, ":") then
if num == 1 then
chargesStr = gsub(chargesStr, "\1244(.+):.+;", "%1")
else
chargesStr = gsub(chargesStr, "\1244.+:(.+);", "%1")
end
end
local maxCharges = text and strmatch(text, "^"..chargesStr.."$")
if maxCharges then
return maxCharges
end
end
end

View File

@@ -0,0 +1,436 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ItemFilter = TSM.Init("Service.ItemFilter")
local LibTSMClass = TSM.Include("LibTSMClass")
local L = TSM.Include("Locale").GetTable()
local Filter = LibTSMClass.DefineClass("ItemFilter")
local ItemClass = TSM.Include("Data.ItemClass")
local Money = TSM.Include("Util.Money")
local String = TSM.Include("Util.String")
local Vararg = TSM.Include("Util.Vararg")
local Log = TSM.Include("Util.Log")
local ItemInfo = TSM.Include("Service.ItemInfo")
-- ============================================================================
-- Module Functions
-- ============================================================================
function ItemFilter.New()
return Filter()
end
-- ============================================================================
-- Filter Class
-- ============================================================================
function Filter.__init(self)
self._isValid = nil
self._str = nil
self._escapedStr = nil
self._class = nil
self._subClass = nil
self._invSlotId = nil
self._minQuality = nil
self._maxQuality = nil
self._minLevel = nil
self._maxLevel = nil
self._minItemLevel = nil
self._maxItemLevel = nil
self._minPrice = nil
self._maxPrice = nil
self._maxQuantity = nil
self._uncollected = nil
self._usable = nil
self._upgrades = nil
self._unlearned = nil
self._canlearn = nil
self._exactOnly = nil
self._crafting = nil
self._disenchant = nil
self._item = nil
self:_Reset()
end
function Filter._Reset(self)
self._isValid = nil
self._str = ""
self._escapedStr = ""
self._class = nil
self._subClass = nil
self._invSlotId = nil
self._minQuality = -math.huge
self._maxQuality = math.huge
self._minLevel = 0
self._maxLevel = math.huge
self._minItemLevel = 0
self._maxItemLevel = math.huge
self._minPrice = 0
self._maxPrice = math.huge
self._maxQuantity = math.huge
self._uncollected = nil
self._usable = nil
self._upgrades = nil
self._unlearned = nil
self._canlearn = nil
self._exactOnly = nil
self._crafting = nil
self._disenchant = nil
self._item = nil
end
function Filter._ItemQualityToIndex(self, str)
for i = 0, 7 do
local text = _G["ITEM_QUALITY"..i.."_DESC"]
if strlower(str) == strlower(text) then
return i
end
end
end
function Filter.ParseStr(self, str)
self:_Reset()
local errMsg = nil
local numLevelParts, numItemLevelParts, numPriceParts, numQualityParts = 0, 0, 0, 0
self._isValid = nil
local hasNonCraftingPart = false
for i, part in Vararg.Iterator(strsplit("/", strtrim(str))) do
part = strtrim(part)
if self._isValid ~= nil then
-- already done iterating, but can't break / return out of a VarargIterator
if strmatch(part, "^[ip]:[0-9]+") then
-- request item info in case we failed due to not having it (for next time)
ItemInfo.FetchInfo(part)
end
elseif i == 1 then
-- first part must be a filter string or an item
if strmatch(part, "^[ip]:[0-9]+") then
local name = ItemInfo.GetName(part)
local level = ItemInfo.GetMinLevel(part)
local quality = ItemInfo.GetQuality(part)
if not name or not level or not quality then
errMsg = L["The specified item was not found."]
self._isValid = false
else
self._exactOnly = true
self._item = part
self._str = strlower(name)
self._escapedStr = String.Escape(self._str)
self._minQuality = quality
self._maxQuality = quality
self._minLevel = level
self._maxLevel = level
self._class = ItemInfo.GetClassId(self._item) or 0
self._subClass = ItemInfo.GetSubClassId(self._item) or 0
end
else
self._str = strlower(part)
self._escapedStr = String.Escape(self._str)
end
elseif part == "" then
-- ignore an empty part
elseif tonumber(part) then
if numLevelParts == 0 then
self._minLevel = tonumber(part)
elseif numLevelParts == 1 then
self._maxLevel = tonumber(part)
else
-- already have min / max level
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
numLevelParts = numLevelParts + 1
hasNonCraftingPart = true
elseif tonumber(strmatch(part, "^i(%d+)$")) then
if numItemLevelParts == 0 then
self._minItemLevel = tonumber(strmatch(part, "^i(%d+)$"))
elseif numItemLevelParts == 1 then
self._maxItemLevel = tonumber(strmatch(part, "^i(%d+)$"))
else
-- already have min / max item level
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
numItemLevelParts = numItemLevelParts + 1
hasNonCraftingPart = true
elseif ItemClass.GetClassIdFromClassString(part) then
self._class = ItemClass.GetClassIdFromClassString(part)
hasNonCraftingPart = true
elseif self._class and ItemClass.GetSubClassIdFromSubClassString(part, self._class) then
self._subClass = ItemClass.GetSubClassIdFromSubClassString(part, self._class)
hasNonCraftingPart = true
elseif ItemClass.GetInventorySlotIdFromInventorySlotString(part) then
self._invSlotId = ItemClass.GetInventorySlotIdFromInventorySlotString(part)
hasNonCraftingPart = true
elseif self:_ItemQualityToIndex(part) then
if numQualityParts == 0 then
self._minQuality = self:_ItemQualityToIndex(part)
elseif numQualityParts == 1 then
self._maxQuality = self:_ItemQualityToIndex(part)
else
-- already have min / max quality
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
numQualityParts = numQualityParts + 1
hasNonCraftingPart = true
elseif Money.FromString(part) then
if numPriceParts == 0 then
self._maxPrice = Money.FromString(part)
elseif numPriceParts == 1 then
self._minPrice = self._maxPrice
self._maxPrice = Money.FromString(part)
else
-- already have min / max price
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
numPriceParts = numPriceParts + 1
hasNonCraftingPart = true
elseif not TSM.IsWowClassic() and strlower(part) == "uncollected" then
if self._uncollected then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._uncollected = true
hasNonCraftingPart = true
elseif strlower(part) == "usable" then
if self._usable then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._usable = true
hasNonCraftingPart = true
elseif not TSM.IsWowClassic() and strlower(part) == "upgrades" then
if self._upgrades then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._upgrades = true
hasNonCraftingPart = true
elseif strlower(part) == "unlearned" then
if self._unlearned then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
if CanIMogIt and CanIMogIt.PlayerKnowsTransmog then
self._unlearned = true
else
Log.PrintUser(L["The unlearned filter was ignored because the CanIMogIt addon was not found."])
end
hasNonCraftingPart = true
elseif strlower(part) == "canlearn" then
if self._canlearn then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
if CanIMogIt and CanIMogIt.CharacterCanLearnTransmog then
self._canlearn = true
else
Log.PrintUser(L["The canlearn filter was ignored because the CanIMogIt addon was not found."])
end
hasNonCraftingPart = true
elseif strlower(part) == "exact" then
if self._exactOnly then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._exactOnly = true
hasNonCraftingPart = true
elseif tonumber(strmatch(part, "^x(%d+)$")) then
self._maxQuantity = tonumber(strmatch(part, "^x(%d+)$"))
if self._maxQuantity == 0 then
errMsg = L["The max quantity cannot be zero."]
self._isValid = false
end
elseif strlower(part) == "crafting" then
if self._crafting or self._disenchant then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._crafting = true
elseif strlower(part) == "disenchant" then
if self._disenchant or self._crafting then
errMsg = L["The same filter was specified multiple times."]
self._isValid = false
end
self._disenchant = true
else
-- invalid part
errMsg = format(L["Unknown word (%s)."], part)
self._isValid = false
end
end
if (self._crafting or self._disenchant) and hasNonCraftingPart then
errMsg = L["Cannot use additional filters with /crafting or /disenchant."]
self._isValid = false
end
if self._isValid == nil then
self._isValid = true
end
return self._isValid, errMsg
end
function Filter.GetStr(self)
return self._str ~= "" and self._str or nil
end
function Filter.GetItem(self)
return self._item
end
function Filter.GetMinQuality(self)
return self._minQuality ~= -math.huge and self._minQuality or nil
end
function Filter.GetMaxQuality(self)
return self._maxQuality ~= math.huge and self._maxQuality or nil
end
function Filter.GetClass(self)
return self._class
end
function Filter.GetSubClass(self)
return self._subClass
end
function Filter.GetInvSlotId(self)
return self._invSlotId
end
function Filter.GetMinLevel(self)
return self._minLevel ~= 0 and self._minLevel or nil
end
function Filter.GetMaxLevel(self)
return self._maxLevel ~= math.huge and self._maxLevel or nil
end
function Filter.GetMinItemLevel(self)
return self._minItemLevel ~= 0 and self._minItemLevel or nil
end
function Filter.GetMaxItemLevel(self)
return self._maxItemLevel ~= math.huge and self._maxItemLevel or nil
end
function Filter.GetUncollected(self)
return self._uncollected
end
function Filter.GetUsableOnly(self)
return self._usable
end
function Filter.GetUpgrades(self)
return self._upgrades
end
function Filter.GetUnlearned(self)
return self._unlearned
end
function Filter.GetCanLearn(self)
return self._canlearn
end
function Filter.GetExactOnly(self)
return self._exactOnly
end
function Filter.GetMaxQuantity(self)
return self._maxQuantity ~= math.huge and self._maxQuantity or nil
end
function Filter.GetMinPrice(self)
return self._minPrice ~= 0 and self._minPrice or nil
end
function Filter.GetMaxPrice(self)
return self._maxPrice ~= math.huge and self._maxPrice or nil
end
function Filter.GetCrafting(self)
return self._crafting
end
function Filter.GetDisenchant(self)
return self._disenchant
end
function Filter.Matches(self, item, price)
if not self._isValid then
return false
end
-- check the name
local name = ItemInfo.GetName(item)
name = name and strlower(name)
if not name or not strfind(name, self._escapedStr) or (self._exactOnly and name ~= self._str) then
return false
end
-- check the quality
local quality = ItemInfo.GetQuality(item)
if not quality or quality < self._minQuality or quality > self._maxQuality then
return false
end
-- check the item level
local itemLevel = ItemInfo.GetItemLevel(item)
if not itemLevel or itemLevel < self._minItemLevel or itemLevel > self._maxItemLevel then
return false
end
-- check the required level
local level = ItemInfo.GetMinLevel(item)
if not level or level < self._minLevel or level > self._maxLevel then
return false
end
-- check the item class
if self._class and ItemInfo.GetClassId(item) ~= self._class then
return false
end
-- check the item subclass
if self._subClass and ItemInfo.GetSubClassId(item) ~= self._subClass then
return false
end
-- check the inventory slot
if self._invSlotId and ItemInfo.GetInvSlotId(item) ~= self._invSlotId then
return false
end
-- check unlearned
if self._unlearned and CanIMogIt:PlayerKnowsTransmog(ItemInfo.GetLink(item)) then
return false
end
-- check canlearn
if self._canlearn and CanIMogIt:CharacterCanLearnTransmog(ItemInfo.GetLink(item)) then
return false
end
-- check the price
price = price or 0
if price < self._minPrice or price > self._maxPrice then
return false
end
-- it passed!
return true
end

1368
LibTSM/Service/ItemInfo.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- ItemLinked Functions.
-- @module ItemLinked
local _, TSM = ...
local ItemLinked = TSM.Init("Service.ItemLinked")
local Table = TSM.Include("Util.Table")
local ItemInfo = TSM.Include("Service.ItemInfo")
local private = {
callbacks = {},
priorityLookup = {}
}
-- ============================================================================
-- Module Loading
-- ============================================================================
ItemLinked:OnModuleLoad(function()
local origHandleModifiedItemClick = HandleModifiedItemClick
HandleModifiedItemClick = function(link)
return private.ItemLinkedHook(origHandleModifiedItemClick, link)
end
local origChatEdit_InsertLink = ChatEdit_InsertLink
ChatEdit_InsertLink = function(link)
return private.ItemLinkedHook(origChatEdit_InsertLink, link)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function ItemLinked.RegisterCallback(callback, priority)
assert(type(callback) == "function")
tinsert(private.callbacks, callback)
private.priorityLookup[callback] = (priority or 0) + #private.callbacks * 0.01
Table.SortWithValueLookup(private.callbacks, private.priorityLookup)
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ItemLinkedHook(origFunc, itemLink)
local putIntoChat = origFunc(itemLink)
if putIntoChat then
return putIntoChat
end
local name = ItemInfo.GetName(itemLink)
if not name or not private.HandleItemLinked(name, itemLink) then
return putIntoChat
end
return true
end
function private.HandleItemLinked(name, itemLink)
for _, callback in ipairs(private.callbacks) do
if callback(name, itemLink) then
return true
end
end
end

View File

@@ -0,0 +1,24 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ItemTooltip = TSM.Init("Service.ItemTooltip")
local Builder = TSM.Include("Service.ItemTooltipClasses.Builder")
local Wrapper = TSM.Include("Service.ItemTooltipClasses.Wrapper")
-- ============================================================================
-- Module Functions
-- ============================================================================
function ItemTooltip.CreateBuilder()
return Builder.Create()
end
function ItemTooltip.SetWrapperPopulateFunction(func)
Wrapper.SetPopulateFunction(func)
end

View File

@@ -0,0 +1,270 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Builder = TSM.Init("Service.ItemTooltipClasses.Builder")
local L = TSM.Include("Locale").GetTable()
local TempTable = TSM.Include("Util.TempTable")
local Math = TSM.Include("Util.Math")
local Money = TSM.Include("Util.Money")
local ItemString = TSM.Include("Util.ItemString")
local Theme = TSM.Include("Util.Theme")
local ItemInfo = TSM.Include("Service.ItemInfo")
local LibTSMClass = TSM.Include("LibTSMClass")
local TooltipBuilder = LibTSMClass.DefineClass("TooltipBuilder")
local private = {}
local TOOLTIP_CACHE_TIME = 5
local LINE_PART_SEP = "\t"
local DISABLED_TINT = -30
-- ============================================================================
-- TooltipBuilder - Class Meta Methods
-- ============================================================================
function Builder.Create()
return TooltipBuilder()
end
-- ============================================================================
-- TooltipBuilder - Class Meta Methods
-- ============================================================================
function TooltipBuilder.__init(self)
self._lines = {}
self._lineColors = {}
self._level = 0
self._levelNumLines = {}
self._levelHasHeading = {}
self._lastUpdate = 0
self._modifier = nil
self._inCombat = false
self._itemString = nil
self._quantity = nil
self._disabled = nil
end
-- ============================================================================
-- TooltipBuilder - Public Methods
-- ============================================================================
function TooltipBuilder.ApplyValueColor(self, text)
local color = Theme.GetColor("TEXT")
if self._disabled then
color = color:GetTint(DISABLED_TINT)
end
return color:ColorText(text)
end
function TooltipBuilder.FormatMoney(self, value, color)
color = color or Theme.GetColor("TEXT")
if self._disabled then
color = color:GetTint(DISABLED_TINT)
end
if not value then
return self:ApplyValueColor("---")
end
return Money.ToString(value, color:GetTextColorPrefix(), TSM.db.global.tooltipOptions.tooltipPriceFormat == "icon" and "OPT_ICON" or nil, self._disabled and "OPT_DISABLE" or nil)
end
function TooltipBuilder.AddLine(self, lineLeft, lineRight, color)
if lineRight then
lineLeft = strjoin(LINE_PART_SEP, lineLeft, lineRight)
end
local line = strrep(" ", self._level)..lineLeft
color = color or Theme.GetColor("INDICATOR_ALT")
if self._disabled then
color = color:GetTint(DISABLED_TINT)
end
assert(not self._lineColors[line] or self._lineColors[line] == color)
self._lineColors[line] = color
tinsert(self._lines, line)
self._levelNumLines[self._level] = self._levelNumLines[self._level] + 1
end
function TooltipBuilder.AddTextLine(self, label, text, ...)
if select("#", ...) == 0 then
self:AddLine(label, self:ApplyValueColor(text))
else
self:AddLine(label, self:ApplyValueColor(text).." ("..self:_TextsToStr(...)..")")
end
end
function TooltipBuilder.AddValueLine(self, label, ...)
self:AddLine(label, self:_ValuesToStr(...))
end
function TooltipBuilder.AddItemValueLine(self, label, value)
if self._quantity > 1 then
label = label.." x"..self._quantity
value = value * self._quantity
end
self:AddLine(label, self:FormatMoney(value))
end
function TooltipBuilder.AddQuantityValueLine(self, label, quantity, ...)
self:AddLine(label, self:ApplyValueColor(quantity).." ("..self:_ValuesToStr(...)..")")
end
function TooltipBuilder.AddSubItemValueLine(self, itemString, value, multiplier, matRate, minAmount, maxAmount)
local name = ItemInfo.GetName(itemString)
local color = ItemInfo.GetQualityColor(itemString)
if not name or not color then
return
end
multiplier = Math.Round(multiplier * self._quantity, 0.001)
matRate = matRate and matRate * 100
matRate = matRate and matRate.."% " or ""
local range = (minAmount and maxAmount) and (minAmount ~= maxAmount and "|cffffff00 ["..minAmount.."-"..maxAmount.."]|r" or "|cffffff00 ["..minAmount.."]|r") or ""
value = value and (value * multiplier) or nil
self:AddLine(color..matRate..name.." x"..multiplier.."|r"..range, self:FormatMoney(value))
end
function TooltipBuilder.StartSection(self, headingTextLeft, headingTextRight)
if self._level == 0 then
headingTextLeft = Theme.GetColor("INDICATOR"):ColorText(headingTextLeft)
end
if headingTextRight then
self:AddLine(headingTextLeft, headingTextRight)
elseif headingTextLeft then
self:AddLine(headingTextLeft)
end
self._level = self._level + 1
self._levelHasHeading[self._level] = headingTextLeft and true or false
self._levelNumLines[self._level] = 0
end
function TooltipBuilder.EndSection(self)
if self._levelNumLines[self._level] == 0 and self._levelHasHeading[self._level] then
-- remove the previous heading line
local headingLine = tremove(self._lines)
assert(headingLine)
self._lineColors[headingLine] = nil
self._levelNumLines[self._level - 1] = self._levelNumLines[self._level - 1] - 1
end
self._level = self._level - 1
assert(self._level >= 0)
end
function TooltipBuilder.SetDisabled(self, disabled)
self._disabled = disabled
end
function TooltipBuilder.GetNumLines(self)
return #self._lines
end
function TooltipBuilder.GetLine(self, index)
local line = self._lines[index]
local left, right = strsplit(LINE_PART_SEP, line)
return left, right, self._lineColors[line]
end
-- ============================================================================
-- TooltipBuilder - Private Methods
-- ============================================================================
function TooltipBuilder._GetModifierHash(self)
return (IsShiftKeyDown() and 4 or 0) + (IsAltKeyDown() and 2 or 0) + (IsControlKeyDown() and 1 or 0)
end
function TooltipBuilder._IsCached(self, itemString, quantity)
if itemString == ItemString.GetPlaceholder() then
return false
end
if self:_GetModifierHash() ~= self._modifier then
return false
end
if self._itemString ~= itemString or self._quantity ~= quantity then
return false
end
if GetTime() - self._lastUpdate >= TOOLTIP_CACHE_TIME then
return false
end
if InCombatLockdown() ~= self._inCombat then
return false
end
return true
end
function TooltipBuilder._Prepare(self, itemString, quantity)
if self:_IsCached(itemString, quantity) then
-- have the lines cached already
return true
end
wipe(self._lines)
wipe(self._lineColors)
wipe(self._levelNumLines)
wipe(self._levelHasHeading)
self._level = 0
self._levelNumLines[self._level] = 0
self._lastUpdate = GetTime()
self._modifier = self:_GetModifierHash()
self._inCombat = InCombatLockdown()
self._itemString = itemString
self._quantity = quantity
self._disabled = false
if InCombatLockdown() and itemString ~= ItemString.GetPlaceholder() then
self:AddLine(L["Can't load TSM tooltip while in combat"])
return true
else
return false
end
end
function TooltipBuilder._IsEmpty(self)
assert(self._level == 0)
return #self._lines == 0
end
function TooltipBuilder._LineIterator(self)
return private.LineIteratorHelper, self, 0
end
function TooltipBuilder._TextsToStr(self, ...)
local valueParts = TempTable.Acquire(...)
for i = 1, #valueParts do
valueParts[i] = self:ApplyValueColor(valueParts[i])
end
local result = table.concat(valueParts, " / ")
TempTable.Release(valueParts)
return result
end
function TooltipBuilder._ValuesToStr(self, ...)
-- can't just build the table with the vararg as there may be nils
local valueParts = TempTable.Acquire()
for i = 1, select("#", ...) do
local value = select(i, ...)
valueParts[i] = self:FormatMoney(value)
end
local result = table.concat(valueParts, " / ")
TempTable.Release(valueParts)
return result
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.LineIteratorHelper(self, index)
index = index + 1
if index > #self._lines then
return
end
return index, self:GetLine(index)
end

View File

@@ -0,0 +1,114 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local ExtraTip = TSM.Init("Service.ItemTooltipClasses.ExtraTip")
local private = {
numExtraTips = 0,
}
-- ============================================================================
-- Module Functions
-- ============================================================================
function ExtraTip.Create(tooltip)
return private.CreateExtraTip(tooltip)
end
-- ============================================================================
-- Extra Tip Methods
-- ============================================================================
local EXTRA_TIP_METHODS = {
Attach = function(self, tooltip)
self:SetOwner(tooltip, "ANCHOR_PRESERVE")
self.anchorFrame = nil
self:OnUpdate()
end,
Show = function(self)
self.anchorFrame = nil
GameTooltip.Show(self)
local bottom = self:GetBottom()
if bottom and self:GetParent():IsShown() then
self.isTop = bottom <= 0
end
self:OnUpdate()
local numLines = self:NumLines()
local changedLines = self.changedLines or 0
if changedLines >= numLines then return end
for i = changedLines + 1, numLines do
local left, right = self.Left[i], self.Right[i]
local font = i == 1 and GameTooltipHeader or GameFontNormal
local r, g, b, a = left:GetTextColor()
left:SetFontObject(font)
left:SetTextColor(r, g, b, a)
r, g, b, a = right:GetTextColor()
right:SetFontObject(font)
right:SetTextColor(r, g, b, a)
end
self.changedLines = numLines
GameTooltip.Show(self)
self:OnUpdate()
end,
OnUpdate = function(self)
local tooltip = self:GetParent()
local anchorFrame = private.GetExtraTipFrame(tooltip, tooltip:GetChildren()) or tooltip
if anchorFrame ~= self.anchorFrame then
self:ClearAllPoints()
if self.isTop then
self:SetPoint("BOTTOM", anchorFrame, "TOP")
else
self:SetPoint("TOP", anchorFrame, "BOTTOM")
end
self.anchorFrame = anchorFrame
end
end,
}
local LINE_METATABLE = {
__index = function(t, k)
local v = _G[t.name..k]
rawset(t, k, v)
return v
end,
}
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.CreateExtraTip(tooltip)
private.numExtraTips = private.numExtraTips + 1
local extraTip = CreateFrame("GameTooltip", "TSMExtraTip"..private.numExtraTips, tooltip, "GameTooltipTemplate")
extraTip:SetClampedToScreen(true)
for name, func in pairs(EXTRA_TIP_METHODS) do
extraTip[name] = func
end
extraTip.Left = setmetatable({name = extraTip:GetName().."TextLeft"}, LINE_METATABLE)
extraTip.Right = setmetatable({name = extraTip:GetName().."TextRight"}, LINE_METATABLE)
return extraTip
end
function private.GetExtraTipFrame(tooltip, ...)
for i = 1, select('#', ...) do
local frame = select(i, ...)
if frame.InitLines and frame:GetParent() == tooltip and frame:IsVisible() then
return frame
end
end
end

View File

@@ -0,0 +1,320 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Wrapper = TSM.Init("Service.ItemTooltipClasses.Wrapper")
local ExtraTip = TSM.Include("Service.ItemTooltipClasses.ExtraTip")
local Builder = TSM.Include("Service.ItemTooltipClasses.Builder")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Settings = TSM.Include("Service.Settings")
local ItemString = TSM.Include("Util.ItemString")
local private = {
builder = nil,
settings = nil,
tooltipRegistry = {},
hookedBattlepetGlobal = nil,
tooltipMethodPrehooks = nil,
tooltipMethodPosthooks = {},
lastMailTooltipUpdate = nil,
lastMailTooltipIndex = nil,
populateFunc = nil,
}
-- ============================================================================
-- Module Loading
-- ============================================================================
Wrapper:OnSettingsLoad(function()
private.builder = Builder.Create()
private.settings = Settings.NewView()
:AddKey("global", "tooltipOptions", "embeddedTooltip")
:AddKey("global", "tooltipOptions", "enabled")
:AddKey("global", "tooltipOptions", "tooltipShowModifier")
private.RegisterTooltip(GameTooltip)
private.RegisterTooltip(ItemRefTooltip)
if not TSM.IsWowClassic() then
private.RegisterTooltip(BattlePetTooltip)
private.RegisterTooltip(FloatingBattlePetTooltip)
end
local orig = OpenMailAttachment_OnEnter
OpenMailAttachment_OnEnter = function(self, index)
private.lastMailTooltipUpdate = private.lastMailTooltipUpdate or 0
if private.lastMailTooltipIndex ~= index or private.lastMailTooltipUpdate + 0.1 < GetTime() then
private.lastMailTooltipUpdate = GetTime()
private.lastMailTooltipIndex = index
orig(self, index)
end
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Wrapper.SetPopulateFunction(func)
assert(type(func) == "function" and not private.populateFunc)
private.populateFunc = func
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.RegisterTooltip(tooltip)
local reg = {}
reg.extraTip = ExtraTip.Create(tooltip)
private.tooltipRegistry[tooltip] = reg
if private.IsBattlePetTooltip(tooltip) then
if not private.hookedBattlepetGlobal then
private.hookedBattlepetGlobal = true
hooksecurefunc("BattlePetTooltipTemplate_SetBattlePet", private.OnTooltipSetBattlePet)
hooksecurefunc("BattlePetToolTip_Show", private.OnBattlePetTooltipShow)
end
tooltip:HookScript("OnHide", private.OnTooltipCleared)
else
local scriptHooks = {
OnTooltipSetItem = private.OnTooltipSetItem,
OnTooltipCleared = private.OnTooltipCleared
}
for script, prehook in pairs(scriptHooks) do
tooltip:HookScript(script, prehook)
end
for method, prehook in pairs(private.tooltipMethodPrehooks) do
local posthook = private.tooltipMethodPosthooks[method]
local orig = tooltip[method]
tooltip[method] = function(...)
prehook(...)
local a, b, c, d, e, f, g, h, i, j, k = orig(...)
posthook(...)
return a, b, c, d, e, f, g, h, i, j, k
end
end
end
end
function private.IsBattlePetTooltip(tooltip)
if TSM.IsWowClassic() then
return false
end
return tooltip == BattlePetTooltip or tooltip == FloatingBattlePetTooltip
end
function private.OnTooltipSetItem(tooltip)
local reg = private.tooltipRegistry[tooltip]
if reg.hasItem then
return
end
tooltip:Show()
local testName, item = tooltip:GetItem()
if not item then
item = reg.item
elseif testName == "" then
-- this is likely a case where :GetItem() is broken for recipes - detect and try to fix it
if strmatch(item, "item:([0-9]*):") == "" then
item = reg.item
end
end
if not item then
return
end
private.SetTooltipItem(tooltip, item)
end
function private.OnTooltipSetBattlePet(tooltip, data)
local reg = private.tooltipRegistry[tooltip]
if reg.hasItem then
private.OnTooltipCleared(tooltip)
end
local link = reg.item
if not link then
-- extract values from data
local speciesID = data.speciesID
local level = data.level
local maxHealth = data.maxHealth
local power = data.power
local speed = data.speed
local battlePetID = data.battlePetID or "0x0000000000000000"
local name = data.name
local customName = data.customName
local breedQuality = data.breedQuality
local colorCode = breedQuality == -1 and NORMAL_FONT_COLOR_CODE or (ITEM_QUALITY_COLORS[breedQuality] or ITEM_QUALITY_COLORS[0]).hex
link = format("%s|Hbattlepet:%d:%d:%d:%d:%d:%d:%s|h[%s]|h|r", colorCode, speciesID, level, breedQuality, maxHealth, power, speed, battlePetID, customName or name)
end
private.SetTooltipItem(tooltip, link)
end
function private.OnTooltipCleared(tooltip)
local reg = private.tooltipRegistry[tooltip]
if reg.ignoreOnCleared then return end
reg.extraTipUsed = nil
reg.minWidth = 0
reg.quantity = nil
reg.hasItem = nil
reg.item = nil
reg.extraTip:Hide()
reg.extraTip.minWidth = 0
reg.extraTip.isTop = nil
end
function private.OnBattlePetTooltipShow()
local reg = private.tooltipRegistry[BattlePetTooltip]
reg.extraTip:Show()
end
function private.SetTooltipItem(tooltip, link)
local itemString = ItemString.Get(link)
if not private.IsEnabled() or not itemString then
return
end
local reg = private.tooltipRegistry[tooltip]
local quantity = max(IsShiftKeyDown() and reg.quantity or 1, 1)
local isCached = private.builder:_Prepare(itemString, quantity)
if not isCached then
-- populate all the lines
private.populateFunc(private.builder, itemString)
end
if private.builder:_IsEmpty() then
return
end
reg.hasItem = true
local useExtraTip = private.IsBattlePetTooltip(tooltip) or not private.settings.embeddedTooltip
-- setup the extra tip if necessary
if useExtraTip then
reg.extraTip:Attach(tooltip)
local r, g, b = GetItemQualityColor(ItemInfo.GetQuality(link) or 0)
reg.extraTip:AddLine(ItemInfo.GetName(link), r, g, b)
end
-- add all the lines
local targetTip = useExtraTip and reg.extraTip or tooltip
targetTip:AddLine(" ")
for _, left, right, lineColor in private.builder:_LineIterator() do
local r, g, b = lineColor:GetFractionalRGBA()
if right then
targetTip:AddDoubleLine(left, right, r, g, b, r, g, b)
else
targetTip:AddLine(left, r, g, b)
end
end
-- show the tooltip / extra tip as necessary
if not private.IsBattlePetTooltip(tooltip) then
tooltip:Show()
end
if useExtraTip then
reg.extraTip:Show()
end
end
function private.IsEnabled()
if not private.settings.enabled then
return false
elseif private.settings.tooltipShowModifier == "alt" and not IsAltKeyDown() then
return false
elseif private.settings.tooltipShowModifier == "ctrl" and not IsControlKeyDown() then
return false
end
return true
end
-- ============================================================================
-- Hook Setup Code
-- ============================================================================
do
local function PreHookHelper(self, quantityFunc, quantityOffset, ...)
private.OnTooltipCleared(self)
local reg = private.tooltipRegistry[self]
reg.ignoreOnCleared = true
if type(quantityFunc) == "number" then
reg.quantity = quantityFunc
else
reg.quantity = select(quantityOffset, quantityFunc(...))
end
return reg
end
private.tooltipMethodPrehooks = {
SetQuestItem = function(self, ...) PreHookHelper(self, GetQuestItemInfo, 3, ...) end,
SetQuestLogItem = function(self, type, ...)
local quantityFunc = type == "choice" and GetQuestLogChoiceInfo or GetQuestLogRewardInfo
PreHookHelper(self, quantityFunc, 3, ...)
end,
SetRecipeReagentItem = function(self, ...)
local reg = PreHookHelper(self, C_TradeSkillUI.GetRecipeReagentInfo, 3, ...)
reg.item = C_TradeSkillUI.GetRecipeReagentItemLink(...)
end,
SetRecipeResultItem = function(self, ...)
private.OnTooltipCleared(self)
local reg = private.tooltipRegistry[self]
reg.ignoreOnCleared = true
local lNum, hNum = C_TradeSkillUI.GetRecipeNumItemsProduced(...)
-- the quantity can be a range, so use a quantity of 1 if so
reg.quantity = lNum == hNum and lNum or 1
end,
SetBagItem = function(self, ...) PreHookHelper(self, GetContainerItemInfo, 2, ...) end,
SetGuildBankItem = function(self, ...)
local reg = PreHookHelper(self, GetGuildBankItemInfo, 2, ...)
reg.item = GetGuildBankItemLink(...)
end,
SetVoidItem = function(self, ...) PreHookHelper(self, 1) end,
SetVoidDepositItem = function(self, ...) PreHookHelper(self, 1) end,
SetVoidWithdrawalItem = function(self, ...) PreHookHelper(self, 1) end,
SetInventoryItem = function(self, ...) PreHookHelper(self, GetInventoryItemCount, 1, ...) end,
SetMerchantItem = function(self, ...)
local reg = PreHookHelper(self, GetMerchantItemInfo, 4, ...)
reg.item = GetMerchantItemLink(...)
end,
SetMerchantCostItem = function(self, ...) PreHookHelper(self, GetMerchantItemCostItem, 2, ...) end,
SetBuybackItem = function(self, ...) PreHookHelper(self, GetBuybackItemInfo, 4, ...) end,
SetAuctionItem = function(self, ...)
local reg = PreHookHelper(self, GetAuctionItemInfo, 3, ...)
reg.item = GetAuctionItemLink(...)
end,
SetAuctionSellItem = function(self, ...) PreHookHelper(self, GetAuctionSellItemInfo, 3, ...) end,
SetInboxItem = function(self, index) PreHookHelper(self, GetInboxItem, 4, index, 1) end,
SetSendMailItem = function(self, ...) PreHookHelper(self, GetSendMailItem, 4, ...) end,
SetLootItem = function(self, ...) PreHookHelper(self, GetLootSlotInfo, 3, ...) end,
SetLootRollItem = function(self, ...) PreHookHelper(self, GetLootRollItemInfo, 3, ...) end,
SetTradePlayerItem = function(self, ...) PreHookHelper(self, GetTradePlayerItemInfo, 3, ...) end,
SetTradeTargetItem = function(self, ...) PreHookHelper(self, GetTradeTargetItemInfo, 3, ...) end,
SetHyperlink = function(self, link)
local reg = private.tooltipRegistry[self]
private.OnTooltipCleared(self)
reg.ignoreOnCleared = true
reg.item = link
end,
}
-- populate all the posthooks
local function TooltipMethodPostHook(self)
private.tooltipRegistry[self].ignoreOnCleared = nil
end
for funcName in pairs(private.tooltipMethodPrehooks) do
private.tooltipMethodPosthooks[funcName] = TooltipMethodPostHook
end
-- SetHyperlink is special
private.tooltipMethodPosthooks.SetHyperlink = function(self)
local reg = private.tooltipRegistry[self]
reg.ignoreOnCleared = nil
end
end

View File

@@ -0,0 +1,398 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local MailTracking = TSM.Init("Service.MailTracking")
local Database = TSM.Include("Util.Database")
local Delay = TSM.Include("Util.Delay")
local Event = TSM.Include("Util.Event")
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local ItemString = TSM.Include("Util.ItemString")
local ItemInfo = TSM.Include("Service.ItemInfo")
local Settings = TSM.Include("Service.Settings")
local AuctionTracking = TSM.Include("Service.AuctionTracking")
local private = {
settings = nil,
mailDB = nil,
itemDB = nil,
quantityDB = nil,
isOpen = false,
tooltip = nil,
callbacks = {},
expiresCallbacks = {},
cancelAuctionQuery = nil,
}
local PLAYER_NAME = UnitName("player")
-- ============================================================================
-- Module Loading
-- ============================================================================
MailTracking:OnSettingsLoad(function()
private.settings = Settings.NewView()
:AddKey("factionrealm", "internalData", "pendingMail")
:AddKey("factionrealm", "internalData", "expiringMail")
:AddKey("sync", "internalData", "mailQuantity")
-- update the structure of TSM.db.factionrealm.internalData.pendingMail
local toUpdate = TempTable.Acquire()
for character, pendingMailData in pairs(private.settings.pendingMail) do
if pendingMailData.items then
Log.Info("Converting pending mail data for %s", character)
toUpdate[character] = pendingMailData.items
end
end
for character, items in pairs(toUpdate) do
private.settings.pendingMail[character] = items
end
TempTable.Release(toUpdate)
-- remove data for characters we don't own
local toRemove = TempTable.Acquire()
for character in pairs(private.settings.pendingMail) do
if Settings.GetCharacterSyncAccountKey(character) ~= Settings.GetCurrentSyncAccountKey() then
Log.Info("Removed pending mail data for %s", character)
tinsert(toRemove, character)
end
end
for _, character in ipairs(toRemove) do
private.settings.pendingMail[character] = nil
end
TempTable.Release(toRemove)
private.mailDB = Database.NewSchema("MAIL_TRACKING_INBOX_INFO")
:AddUniqueNumberField("index")
:AddStringField("icon")
:AddStringField("sender")
:AddStringField("subject")
:AddStringField("itemString")
:AddNumberField("itemCount")
:AddNumberField("money")
:AddNumberField("cod")
:AddNumberField("expires")
:AddIndex("index")
:Commit()
private.itemDB = Database.NewSchema("MAIL_TRACKING_INBOX_ITEMS")
:AddNumberField("index")
:AddNumberField("itemIndex")
:AddStringField("itemLink")
:AddNumberField("quantity")
:Commit()
private.quantityDB = Database.NewSchema("MAIL_TRACKING_QUANTITY")
:AddUniqueStringField("itemString")
:AddNumberField("quantity")
:Commit()
private.settings.pendingMail[PLAYER_NAME] = private.settings.pendingMail[PLAYER_NAME] or {}
Event.Register("MAIL_SHOW", private.MailShowHandler)
Event.Register("MAIL_CLOSED", private.MailClosedHandler)
Event.Register("MAIL_INBOX_UPDATE", private.MailInboxUpdateHandler)
if TSM.IsWowClassic() then
-- handle auction buying
hooksecurefunc("PlaceAuctionBid", function(listType, index, bidPlaced)
local itemString = ItemString.GetBase(GetAuctionItemLink(listType, index))
local _, _, stackSize, _, _, _, _, _, _, buyout = GetAuctionItemInfo(listType, index)
if not itemString or bidPlaced ~= buyout then
return
end
private.ChangePendingMailQuantity(itemString, stackSize)
end)
-- handle auction canceling
hooksecurefunc("CancelAuction", function(index)
local itemString = ItemString.GetBase(GetAuctionItemLink("owner", index))
local _, _, stackSize = GetAuctionItemInfo("owner", index)
-- for some reason, these APIs don't always work properly, so check the return values
if not itemString or not stackSize or stackSize == 0 then
return
end
private.ChangePendingMailQuantity(itemString, stackSize)
end)
else
private.cancelAuctionQuery = AuctionTracking.CreateQuery()
:Equal("auctionId", Database.BoundQueryParam())
:Select("itemString", "stackSize")
-- handle auction canceling
hooksecurefunc(C_AuctionHouse, "CancelAuction", function(auctionId)
private.cancelAuctionQuery:BindParams(auctionId)
for _, itemString, stackSize in private.cancelAuctionQuery:Iterator() do
private.ChangePendingMailQuantity(itemString, stackSize)
end
end)
end
-- handle sending mail to alts
hooksecurefunc("SendMail", function(target)
local character = private.ValidateCharacter(target)
if not character then
return
end
private.settings.pendingMail[character] = private.settings.pendingMail[character] or {}
local altPendingMail = private.settings.pendingMail[character]
for i = 1, ATTACHMENTS_MAX_SEND do
local itemString = ItemString.GetBase(GetSendMailItemLink(i))
local _, _, _, quantity = GetSendMailItem(i)
if itemString and quantity then
altPendingMail[itemString] = (altPendingMail[itemString] or 0) + quantity
end
end
end)
-- handle returning mail to alts
hooksecurefunc("ReturnInboxItem", function(index)
local character = private.ValidateCharacter(select(3, GetInboxHeaderInfo(index)))
if not character then
return
end
private.settings.pendingMail[character] = private.settings.pendingMail[character] or {}
local altPendingMail = private.settings.pendingMail[character]
for i = 1, ATTACHMENTS_MAX_SEND do
local _, _, _, quantity = GetInboxItem(index, i)
local itemLink = quantity and quantity > 0 and private.GetInboxItemLink(index, i) or nil
local itemString = itemLink and ItemString.GetBase(itemLink) or nil
if itemString then
altPendingMail[itemString] = (altPendingMail[itemString] or 0) + quantity
end
end
end)
private.quantityDB:BulkInsertStart()
for itemString, quantity in pairs(private.settings.pendingMail[PLAYER_NAME]) do
if quantity > 0 then
private.quantityDB:BulkInsertNewRow(itemString, quantity)
else
private.settings.pendingMail[PLAYER_NAME][itemString] = nil
end
end
private.quantityDB:BulkInsertEnd()
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function MailTracking.RegisterCallback(callback)
tinsert(private.callbacks, callback)
end
function MailTracking.RegisterExpiresCallback(callback)
tinsert(private.expiresCallbacks, callback)
end
function MailTracking.BaseItemIterator()
return private.quantityDB:NewQuery()
:Select("itemString")
:IteratorAndRelease()
end
function MailTracking.CreateMailInboxQuery()
return private.mailDB:NewQuery()
end
function MailTracking.CreateMailItemQuery()
return private.itemDB:NewQuery()
end
function MailTracking.GetInboxItemLink(index)
return private.GetInboxItemLink(index, 1)
end
function MailTracking.GetMailType(index)
return private.GetMailType(index)
end
function MailTracking.GetQuantityByBaseItemString(baseItemString)
return private.quantityDB:GetUniqueRowField("itemString", baseItemString, "quantity") or 0
end
function MailTracking.RecordAuctionBuyout(baseItemString, stackSize)
if TSM.IsWowClassic() then
-- on classic, we'll handle auction buys via a direct hook
return
end
private.ChangePendingMailQuantity(baseItemString, stackSize)
end
-- ============================================================================
-- Event Handlers
-- ============================================================================
function private.MailShowHandler()
private.isOpen = true
end
function private.MailClosedHandler()
private.isOpen = false
end
function private.MailInboxUpdateHandler()
if not private.isOpen then
return
end
Delay.AfterFrame("mailInboxScan", 1, private.MailInboxUpdateDelayed)
end
function private.MailInboxUpdateDelayed()
if not private.isOpen then
return
end
wipe(private.settings.mailQuantity)
wipe(private.settings.pendingMail[PLAYER_NAME])
private.mailDB:TruncateAndBulkInsertStart()
private.itemDB:TruncateAndBulkInsertStart()
local expiration = math.huge
for i = 1, GetInboxNumItems() do
local _, _, sender, subject, money, cod, daysLeft, itemCount = GetInboxHeaderInfo(i)
if itemCount and itemCount > 0 and money and money > 0 then
expiration = min(expiration, time() + (daysLeft * 24 * 60 * 60))
end
if money and money > 0 then
private.itemDB:BulkInsertNewRow(i, 0, tostring(money), 0)
end
local firstItemString = nil
for j = 1, ATTACHMENTS_MAX do
local _, _, _, quantity = GetInboxItem(i, j)
local itemLink = quantity and quantity > 0 and private.GetInboxItemLink(i, j) or nil
local itemString = itemLink and ItemString.Get(itemLink) or nil
if itemString then
firstItemString = firstItemString or itemString
local baseItemString = ItemString.GetBaseFast(itemString)
private.settings.mailQuantity[baseItemString] = (private.settings.mailQuantity[baseItemString] or 0) + quantity
private.itemDB:BulkInsertNewRow(i, j, itemLink, quantity)
end
end
local mailType = private.GetMailType(i, firstItemString) or ""
if mailType == "BUY" then
local _, _, _, bid = GetInboxInvoiceInfo(i)
cod = bid
end
private.mailDB:BulkInsertNewRow(i, mailType, sender or UNKNOWN, subject or "--", firstItemString or "", itemCount or 0, money or 0, cod or 0, daysLeft)
end
private.quantityDB:TruncateAndBulkInsertStart()
for itemString, quantity in pairs(private.settings.mailQuantity) do
private.quantityDB:BulkInsertNewRow(itemString, quantity)
end
private.quantityDB:BulkInsertEnd()
private.itemDB:BulkInsertEnd()
private.mailDB:BulkInsertEnd()
private.settings.expiringMail[PLAYER_NAME] = expiration ~= math.huge and expiration or nil
for _, callback in ipairs(private.expiresCallbacks) do
callback()
end
for _, callback in ipairs(private.callbacks) do
callback()
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.ChangePendingMailQuantity(itemString, quantity)
assert(quantity ~= 0)
private.settings.pendingMail[PLAYER_NAME][itemString] = (private.settings.pendingMail[PLAYER_NAME][itemString] or 0) + quantity
if not private.quantityDB:HasUniqueRow("itemString", itemString) then
-- create a new row
private.quantityDB:NewRow()
:SetField("itemString", itemString)
:SetField("quantity", quantity)
:Create()
else
local row = private.quantityDB:GetUniqueRow("itemString", itemString)
local newValue = row:GetField("quantity") + quantity
assert(newValue >= 0)
if newValue == 0 then
-- remove this row
private.quantityDB:DeleteRow(row)
else
-- update this row
row:SetField("quantity", newValue)
:Update()
end
row:Release()
end
for _, callback in ipairs(private.callbacks) do
callback()
end
end
function private.ValidateCharacter(character)
if not character then
return
end
local characterName, realm = strsplit("-", strlower(character))
-- we only care to track mails with characters on this realm
if realm and realm ~= strlower(GetRealmName()) then
return
end
-- we only care to track mails with characters on this account
local result = nil
for _, name in Settings.CharacterByAccountFactionrealmIterator() do
if strlower(name) == characterName then
result = name
end
end
return result
end
function private.GetInboxItemLink(index, num)
local link = GetInboxItemLink(index, num)
if ItemString.GetBase(link) ~= ItemString.GetPetCage() then
return link
end
-- need to do tooltip scanning to get battlepet links
private.tooltip = private.tooltip or CreateFrame("GameTooltip", "TSM4MailingInboxTooltip", UIParent, "GameTooltipTemplate")
private.tooltip:SetOwner(UIParent, "ANCHOR_NONE")
private.tooltip:ClearLines()
local _, speciesId, level, breedQuality = private.tooltip:SetInboxItem(index, num)
assert(speciesId and speciesId > 0)
private.tooltip:Hide()
return ItemInfo.GetLink(strjoin(":", "p", speciesId, level, breedQuality))
end
function private.GetMailType(index, firstItemString)
local _, _, _, subject, money, cod, _, numItems, _, _, _, _, isGM = GetInboxHeaderInfo(index)
if isGM or (cod and cod > 0) or (money == 0 and (not numItems or numItems == 0)) then
return nil
end
local info = GetInboxInvoiceInfo(index)
if money and money > 0 and info == "seller" then
return "SALE"
elseif numItems and numItems > 0 and info == "buyer" then
return "BUY"
elseif not info then
if strfind(subject, string.gsub("^"..AUCTION_REMOVED_MAIL_SUBJECT, "%%s", "")) then
return "CANCEL"
end
if strfind(subject, string.gsub("^"..AUCTION_EXPIRED_MAIL_SUBJECT, "%%s", "")) then
return "EXPIRE"
end
end
return "OTHER"
end

View File

@@ -0,0 +1,157 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- PlayerInfo Functions
-- @module PlayerInfo
local _, TSM = ...
local PlayerInfo = TSM.Init("Service.PlayerInfo")
local String = TSM.Include("Util.String")
local Settings = TSM.Include("Service.Settings")
local private = {
connectedAlts = {},
settings = nil,
isPlayerCache = {},
}
local PLAYER_NAME = UnitName("player")
local PLAYER_LOWER = strlower(PLAYER_NAME)
local FACTION_LOWER = strlower(UnitFactionGroup("player"))
local REALM_LOWER = strlower(GetRealmName())
local PLAYER_REALM_LOWER = PLAYER_LOWER.." - "..REALM_LOWER
-- ============================================================================
-- Module Loading
-- ============================================================================
PlayerInfo:OnSettingsLoad(function()
private.settings = Settings.NewView()
:AddKey("factionrealm", "internalData", "guildVaults")
:AddKey("factionrealm", "coreOptions", "ignoreGuilds")
:AddKey("factionrealm", "internalData", "characterGuilds")
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Return all connected realm alternative characters.
-- @return table The populated alternative characters.
function PlayerInfo.GetConnectedAlts()
wipe(private.connectedAlts)
for factionrealm in TSM.db:GetConnectedRealmIterator("factionrealm") do
for _, character in TSM.db:FactionrealmCharacterIterator(factionrealm) do
local realm = strmatch(factionrealm, ".+ %- (.+)")
character = Ambiguate(gsub(strmatch(character, "(.*) ?"..String.Escape("-").."?").."-"..gsub(realm, String.Escape("-"), ""), " ", ""), "none")
if character ~= UnitName("player") then
tinsert(private.connectedAlts, character)
end
end
end
sort(private.connectedAlts)
return private.connectedAlts
end
--- Iterate over all characters on this factionrealm.
-- @tparam[opt=false] boolean currentAccountOnly If true, will only include the current account
-- @return An iterator with the following fields: `index, name`
function PlayerInfo.CharacterIterator(currentAccountOnly)
if currentAccountOnly then
return Settings.CharacterByAccountFactionrealmIterator()
else
return Settings.FactionrealmCharacterIterator()
end
end
--- Iterate over all guilds on this factionrealm.
-- @tparam[opt=false] boolean includeIgnored If true, will include guilds which have been set to be ignored
-- @return An iterator with the following fields: `index, guildName`
function PlayerInfo.GuildIterator(includeIgnored)
if includeIgnored then
return private.GuildIteratorIgnoreIncluded, private.settings.guildVaults
else
return private.GuildIterator, private.settings.guildVaults
end
end
--- Get the player's guild.
-- @tparam string player The name of the player
-- @treturn ?string The name of the player's guilde or nil if it's not in one
function PlayerInfo.GetPlayerGuild(player)
return player and private.settings.characterGuilds[player] or nil
end
--- Check whether or not a player belongs to the user.
-- @tparam string target The name of the player
-- @tparam boolean includeAlts Whether or not to include alts
-- @tparam boolean includeOtherFaction Whether or not to include players on the other faction
-- @tparam boolean includeOtherAccounts Whether or not to include connected accounts
-- @treturn boolean Whether or not the player belongs to the user
function PlayerInfo.IsPlayer(target, includeAlts, includeOtherFaction, includeOtherAccounts)
local cacheKey = strjoin("%", target, includeAlts and "1" or "0", includeOtherFaction and "1" or "0", includeOtherAccounts and "1" or "0")
if private.isPlayerCache.lastUpdate ~= GetTime() then
wipe(private.isPlayerCache)
private.isPlayerCache.lastUpdate = GetTime()
end
if private.isPlayerCache[cacheKey] == nil then
private.isPlayerCache[cacheKey] = private.IsPlayerHelper(target, includeAlts, includeOtherFaction, includeOtherAccounts)
end
return private.isPlayerCache[cacheKey]
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.IsPlayerHelper(target, includeAlts, includeOtherFaction, includeOtherAccounts)
target = strlower(target)
if not strfind(target, " %- ") then
target = gsub(target, "%-", " - ", 1)
end
if target == PLAYER_LOWER then
return true
elseif strfind(target, " %- ") and target == PLAYER_REALM_LOWER then
return true
end
if not strfind(target, " %- ") then
target = target.." - "..REALM_LOWER
end
if includeAlts then
local result = false
for _, factionrealm, character in Settings.ConnectedFactionrealmAltCharacterIterator() do
local factionKey, realm = strmatch(factionrealm, "(.+) %- (.+)")
factionKey = strlower(factionKey)
if not result and target == strlower(character).." - "..strlower(realm) and (includeOtherFaction or factionKey == FACTION_LOWER) and (includeOtherAccounts or Settings.IsCurrentAccountOwner(character)) then
result = true
end
end
return result
end
return false
end
function private.GuildIterator(tbl, prevName)
while true do
local name = next(tbl, prevName)
if not name then
return nil
end
if not private.settings.ignoreGuilds[name] then
return name
end
prevName = name
end
end
function private.GuildIteratorIgnoreIncluded(tbl, prevName)
local name = next(tbl, prevName)
return name
end

1630
LibTSM/Service/Settings.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local SlashCommands = TSM.Init("Service.SlashCommands")
local Log = TSM.Include("Util.Log")
local L = TSM.Include("Locale").GetTable()
local private = {
commandInfo = {},
commandOrder = {},
}
local CALLBACK_TIME_WARNING_MS = 20
-- ============================================================================
-- Module Loading
-- ============================================================================
SlashCommands:OnModuleLoad(function()
-- register the TSM slash commands
SlashCmdList["TSM"] = private.OnChatCommand
SlashCmdList["TRADESKILLMASTER"] = private.OnChatCommand
_G["SLASH_TSM1"] = "/tsm"
_G["SLASH_TRADESKILLMASTER1"] = "/tradeskillmaster"
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function SlashCommands.Register(key, callback, label)
assert(key and callback)
local keyLower = strlower(key)
private.commandInfo[keyLower] = {
key = key,
label = label,
callback = callback,
}
tinsert(private.commandOrder, keyLower)
end
function SlashCommands.PrintHelp()
Log.PrintUser(L["Slash Commands:"])
for _, key in ipairs(private.commandOrder) do
local info = private.commandInfo[key]
if info.label then
if info.key == "" then
Log.PrintfUserRaw("|cffffaa00/tsm|r - %s", info.label)
else
Log.PrintfUserRaw("|cffffaa00/tsm %s|r - %s", info.key, info.label)
end
end
end
end
-- ============================================================================
-- Helper Functions
-- ============================================================================
function private.OnChatCommand(input)
input = strtrim(input)
local cmd, args = strmatch(input, "^([^ ]*) ?(.*)$")
cmd = strlower(cmd)
if private.commandInfo[cmd] then
local startTime = debugprofilestop()
private.commandInfo[cmd].callback(args)
local timeTaken = debugprofilestop() - startTime
if timeTaken > CALLBACK_TIME_WARNING_MS then
Log.Warn("Handler for slash command (/tsm%s) took %0.2fms", input ~= "" and " "..input or input, timeTaken)
end
else
-- We weren't able to handle this command so print out the help
SlashCommands.PrintHelp()
end
end

57
LibTSM/Service/Sync.lua Normal file
View File

@@ -0,0 +1,57 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Sync = TSM.Init("Service.Sync")
local Connection = TSM.Include("Service.SyncClasses.Connection")
local RPC = TSM.Include("Service.SyncClasses.RPC")
local Mirror = TSM.Include("Service.SyncClasses.Mirror")
-- ============================================================================
-- Module Functions
-- ============================================================================
function Sync.RegisterConnectionChangedCallback(callback)
Connection.RegisterConnectionChangedCallback(callback)
end
function Sync.RegisterRPC(name, func)
RPC.Register(name, func)
end
function Sync.CallRPC(name, targetPlayer, handler, ...)
return RPC.Call(name, targetPlayer, handler, ...)
end
function Sync.GetConnectionStatus(account)
return Connection.GetStatus(account)
end
function Sync.GetConnectedCharacterByAccount(account)
return Connection.GetConnectedCharacterByAccount(account)
end
function Sync.GetMirrorStatus(account)
return Mirror.GetStatus(account)
end
function Sync.RegisterMirrorCallback(callback)
Mirror.RegisterCallback(callback)
end
function Sync.EstablishConnection(character)
return Connection.Establish(character)
end
function Sync.GetNewAccountStatus()
return Connection.GetNewAccountStatus()
end
function Sync.RemoveAccount(account)
Connection.Remove(account)
end

View File

@@ -0,0 +1,134 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Comm = TSM.Init("Service.SyncClasses.Comm")
local Delay = TSM.Include("Util.Delay")
local Table = TSM.Include("Util.Table")
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local Settings = TSM.Include("Service.Settings")
local Constants = TSM.Include("Service.SyncClasses.Constants")
local private = {
handler = {},
queuedPacket = {},
queuedSourceCharacter = {},
}
-- load libraries
LibStub("AceComm-3.0"):Embed(Comm)
local LibSerialize = LibStub("LibSerialize")
local LibDeflate = LibStub("LibDeflate")
-- ============================================================================
-- Module Loading
-- ============================================================================
Comm:OnModuleLoad(function()
Comm:RegisterComm("TSMSyncData", private.OnCommReceived)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Comm.RegisterHandler(dataType, handler)
assert(Table.KeyByValue(Constants.DATA_TYPES, dataType) ~= nil)
assert(not private.handler[dataType])
private.handler[dataType] = handler
end
function Comm.SendData(dataType, targetCharacter, data)
assert(type(dataType) == "string" and #dataType == 1)
local packet = TempTable.Acquire()
packet.dt = dataType
packet.sa = Settings.GetCurrentSyncAccountKey()
packet.v = Constants.VERSION
packet.d = data
local serialized = LibSerialize:Serialize(packet)
TempTable.Release(packet)
local compressed = LibDeflate:EncodeForWoWAddonChannel(LibDeflate:CompressDeflate(serialized))
assert(LibDeflate:DecompressDeflate(LibDeflate:DecodeForWoWAddonChannel(compressed)) == serialized)
-- give heartbeats and rpc preambles a higher priority
local priority = (dataType == Constants.DATA_TYPES.HEARTBEAT or dataType == Constants.DATA_TYPES.RPC_PREAMBLE) and "ALERT" or nil
-- send the message
Comm:SendCommMessage("TSMSyncData", compressed, "WHISPER", targetCharacter, priority)
return #compressed
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.OnCommReceived(_, packet, _, sourceCharacter)
-- delay the processing to make sure it happens within a debuggable context (this function is called via pcall)
tinsert(private.queuedPacket, packet)
tinsert(private.queuedSourceCharacter, sourceCharacter)
Delay.AfterFrame("commReceiveQueue", 0, private.ProcessReceiveQueue)
end
function private.ProcessReceiveQueue()
assert(#private.queuedPacket == #private.queuedSourceCharacter)
while #private.queuedPacket > 0 do
local packet = tremove(private.queuedPacket, 1)
local sourceCharacter = tremove(private.queuedSourceCharacter, 1)
private.ProcessReceivedPacket(packet, sourceCharacter)
end
end
function private.ProcessReceivedPacket(msg, sourceCharacter)
-- remove realm name from source player
sourceCharacter = strsplit("-", sourceCharacter)
sourceCharacter = strtrim(sourceCharacter)
local sourceCharacterAccountKey = Settings.GetCharacterSyncAccountKey(sourceCharacter)
if sourceCharacterAccountKey and sourceCharacterAccountKey == Settings.GetCurrentSyncAccountKey() then
Log.Err("We own the source character")
Settings.ShowSyncSVCopyError()
return
end
-- decode and decompress
msg = LibDeflate:DecompressDeflate(LibDeflate:DecodeForWoWAddonChannel(msg))
if not msg then
Log.Err("Invalid packet")
return
end
local success, packet = LibSerialize:Deserialize(msg)
if not success then
Log.Err("Invalid packet")
return
end
-- validate the packet
local dataType = packet.dt
local sourceAccount = packet.sa
local version = packet.v
local data = packet.d
if type(dataType) ~= "string" or #dataType > 1 or not sourceAccount or version ~= Constants.VERSION then
Log.Info("Invalid message received")
return
elseif sourceAccount == Settings.GetCurrentSyncAccountKey() then
Log.Err("We are the source account (SV copy)")
Settings.ShowSyncSVCopyError()
return
elseif sourceCharacterAccountKey and sourceCharacterAccountKey ~= sourceAccount then
-- the source player now belongs to a different account than what we expect
Log.Err("Unexpected source account")
Settings.ShowSyncSVCopyError()
return
end
if private.handler[dataType] then
private.handler[dataType](dataType, sourceAccount, sourceCharacter, data)
else
Log.Info("Received unhandled message of type: "..strbyte(dataType))
end
end

View File

@@ -0,0 +1,443 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Connection = TSM.Init("Service.SyncClasses.Connection")
local L = TSM.Include("Locale").GetTable()
local Delay = TSM.Include("Util.Delay")
local Log = TSM.Include("Util.Log")
local TempTable = TSM.Include("Util.TempTable")
local Event = TSM.Include("Util.Event")
local Settings = TSM.Include("Service.Settings")
local Threading = TSM.Include("Service.Threading")
local Constants = TSM.Include("Service.SyncClasses.Constants")
local Comm = TSM.Include("Service.SyncClasses.Comm")
local private = {
isActive = false,
hasFriendsInfo = false,
newCharacter = nil,
newAccount = nil,
newSyncAcked = nil,
connectionChangedCallbacks = {},
threadId = {},
threadRunning = {},
connectedCharacter = {},
lastHeartbeat = {},
suppressThreadTime = {},
connectionRequestReceived = {},
addedFriends = {},
invalidCharacters = {},
}
local RECEIVE_TIMEOUT = 5
local HEARTBEAT_TIMEOUT = 10
-- ============================================================================
-- Module Loading
-- ============================================================================
Connection:OnSettingsLoad(function()
Event.Register("CHAT_MSG_SYSTEM", private.ChatMsgSystemEventHandler)
Event.Register("FRIENDLIST_UPDATE", private.PrepareFriendsInfo)
for _ in Settings.SyncAccountIterator() do
private.isActive = true
end
Comm.RegisterHandler(Constants.DATA_TYPES.WHOAMI_ACCOUNT, private.WhoAmIAccountHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.WHOAMI_ACK, private.WhoAmIAckHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CONNECTION_REQUEST, private.ConnectionHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CONNECTION_REQUEST_ACK, private.ConnectionHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.DISCONNECT, private.DisconnectHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.HEARTBEAT, private.HeartbeatHandler)
private.PrepareFriendsInfo()
end)
Connection:OnModuleUnload(function()
for _, player in pairs(private.connectedCharacter) do
Comm.SendData(Constants.DATA_TYPES.DISCONNECT, player)
end
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Connection.RegisterConnectionChangedCallback(handler)
tinsert(private.connectionChangedCallbacks, handler)
end
function Connection.IsCharacterConnected(targetCharacter)
for _, player in pairs(private.connectedCharacter) do
if player == targetCharacter then
return true
end
end
return false
end
function Connection.ConnectedAccountIterator()
return pairs(private.connectedCharacter)
end
function Connection.Establish(targetCharacter)
if not private.hasFriendsInfo then
Log.PrintUser(L["TSM is not yet ready to establish a new sync connection. Please try again later."])
return false
end
local wasFriend = C_FriendList.GetFriendInfo(targetCharacter) and true or false
if strlower(targetCharacter) == strlower(UnitName("player")) then
Log.PrintUser(L["Sync Setup Error: You entered the name of the current character and not the character on the other account."])
return false
elseif not private.IsOnline(targetCharacter) and wasFriend then
Log.PrintUser(L["Sync Setup Error: The specified player on the other account is not currently online."])
return false
end
local invalidCharacter = false
for _, player in Settings.CharacterByFactionrealmIterator() do
if strlower(player) == strlower(targetCharacter) then
invalidCharacter = true
end
end
if invalidCharacter then
Log.PrintUser(L["Sync Setup Error: This character is already part of a known account."])
return false
end
if not private.isActive then
private.isActive = true
Delay.AfterTime("SYNC_CONNECTION_MANAGEMENT", 1, private.ManagementLoop, 1)
end
private.newCharacter = targetCharacter
private.newAccount = nil
private.newSyncAcked = nil
Delay.Cancel("syncNewAccount")
Delay.AfterTime("syncNewAccount", 0, private.SendNewAccountWhoAmI, 1)
return true
end
function Connection.GetNewAccountStatus()
if not private.newCharacter then
return nil
end
return format(L["Connecting to %s"], private.newCharacter)
end
function Connection.GetStatus(account)
if private.connectedCharacter[account] then
return true, private.connectedCharacter[account]
else
return false
end
end
function Connection.Remove(account)
if private.threadRunning[account] then
Threading.Kill(private.threadId[account])
private.ConnectionThreadDone(account)
end
Settings.RemoveSyncAccount(account)
end
function Connection.GetConnectedCharacterByAccount(account)
return private.connectedCharacter[account]
end
-- ============================================================================
-- Message Handlers
-- ============================================================================
function private.WhoAmIAckHandler(dataType, sourceAccount, sourceCharacter, data)
assert(dataType == Constants.DATA_TYPES.WHOAMI_ACK)
if not private.newCharacter or strlower(private.newCharacter) ~= strlower(sourceCharacter) then
-- we aren't trying to connect with a new account
return
end
Log.Info("WHOAMI_ACK '%s'", tostring(private.newCharacter))
private.newSyncAcked = true
private.CheckNewAccountStatus()
end
function private.WhoAmIAccountHandler(dataType, sourceAccount, sourceCharacter, data)
assert(dataType == Constants.DATA_TYPES.WHOAMI_ACCOUNT)
if not private.newCharacter then
-- we aren't trying to connect with a new account
return
elseif strlower(private.newCharacter) ~= strlower(sourceCharacter) then
Log.Info("WHOAMI_ACCOUNT from unknown player \"%s\", expected \"%s\"", private.newCharacter, sourceCharacter)
return
end
private.newCharacter = sourceCharacter -- get correct capatilization
private.newAccount = sourceAccount
Log.Info("WHOAMI_ACCOUNT '%s' '%s'", private.newCharacter, private.newAccount)
Comm.SendData(Constants.DATA_TYPES.WHOAMI_ACK, private.newCharacter)
private.CheckNewAccountStatus()
end
function private.ConnectionHandler(dataType, sourceAccount, sourceCharacter, data)
if not private.threadRunning[sourceAccount] then
return
end
private.connectionRequestReceived[sourceAccount] = true
end
function private.DisconnectHandler(dataType, sourceAccount, sourceCharacter, data)
assert(dataType == Constants.DATA_TYPES.DISCONNECT)
if not private.threadRunning[sourceAccount] then
return
end
-- kill the thread and prevent it from running again for 2 seconds
Threading.Kill(private.threadId[sourceAccount])
private.ConnectionThreadDone(sourceAccount)
private.suppressThreadTime[sourceAccount] = time() + 2
end
function private.HeartbeatHandler(dataType, sourceAccount, sourceCharacter)
assert(dataType == Constants.DATA_TYPES.HEARTBEAT)
if not Connection.IsCharacterConnected(sourceCharacter) then
-- we're not connected to this player
return
end
private.lastHeartbeat[sourceAccount] = time()
end
-- ============================================================================
-- Management Loop / Sync Thread
-- ============================================================================
function private.RequestFriendsInfo()
C_FriendList.ShowFriends()
end
function private.PrepareFriendsInfo()
-- wait for friend info to populate
local isValid
local num = C_FriendList.GetNumFriends()
if not num then
isValid = false
else
isValid = true
end
for i = 1, num or 0 do
if not C_FriendList.GetFriendInfoByIndex(i) then
isValid = false
break
end
end
if isValid then
if not private.hasFriendsInfo and private.isActive then
-- start the management loop
Delay.AfterTime("SYNC_CONNECTION_MANAGEMENT", 1, private.ManagementLoop, 1)
end
private.hasFriendsInfo = true
else
-- try again
Log.Err("Missing friends info - will try again")
Delay.AfterTime("SYNC_PREPARE_FRIENDS_INFO", 0.5, private.RequestFriendsInfo)
end
end
function private.ManagementLoop()
-- continuously spawn connection threads with online players as necessary
private.RequestFriendsInfo()
local hasAccount = false
for _, account in Settings.SyncAccountIterator() do
hasAccount = true
local targetCharacter = private.GetTargetCharacter(account)
if targetCharacter then
if not private.threadId[account] then
private.threadId[account] = Threading.New("SYNC_"..strmatch(account, "(%d+)$"), private.ConnectionThread)
end
if not private.threadRunning[account] and (private.suppressThreadTime[account] or 0) < time() then
private.threadRunning[account] = true
Threading.Start(private.threadId[account], account, targetCharacter)
end
end
end
if not hasAccount then
Log.Info("No more sync accounts.")
private.isActive = false
if not private.newCharacter then
Delay.Cancel("SYNC_CONNECTION_MANAGEMENT")
end
end
end
function private.ConnectionThreadInner(account, targetCharacter)
-- for the initial handshake, the lower account key is the server, other is the client - after this it doesn't matter
-- add some randomness to the timeout so we don't get stuck in a race condition
local timeout = GetTime() + RECEIVE_TIMEOUT + random(0, 1000) / 1000
if account < Settings.GetCurrentSyncAccountKey() then
-- wait for the connection request from the client
while not private.connectionRequestReceived[account] do
if GetTime() > timeout then
-- timed out on the connection - don't try again for a bit
Log.Warn("Timed out")
return
end
Threading.Yield(true)
end
-- send an connection request ACK back to the client
Comm.SendData(Constants.DATA_TYPES.CONNECTION_REQUEST_ACK, targetCharacter)
else
-- send a connection request to the server
Comm.SendData(Constants.DATA_TYPES.CONNECTION_REQUEST, targetCharacter)
-- wait for the connection request ACK
while not private.connectionRequestReceived[account] do
if GetTime() > timeout then
-- timed out on the connection - don't try again for a bit
Log.Warn("Timed out")
private.suppressThreadTime[account] = time() + RECEIVE_TIMEOUT
return
end
Threading.Yield(true)
end
end
-- we are now connected
Log.Info("Connected to: %s %s", account, targetCharacter)
private.connectedCharacter[account] = targetCharacter
private.lastHeartbeat[account] = time()
for _, callback in ipairs(private.connectionChangedCallbacks) do
callback(account, targetCharacter, true)
end
-- now that we are connected, data can flow in both directions freely
local lastHeartbeatSend = time()
while true do
-- check if they either logged off or the heartbeats have timed-out
if not private.IsOnline(targetCharacter, true) or time() - private.lastHeartbeat[account] > HEARTBEAT_TIMEOUT then
return
end
-- check if we should send a heartbeat
if time() - lastHeartbeatSend > floor(HEARTBEAT_TIMEOUT / 2) then
Comm.SendData(Constants.DATA_TYPES.HEARTBEAT, targetCharacter)
lastHeartbeatSend = time()
end
Threading.Yield(true)
end
end
function private.ConnectionThread(account, targetCharacter)
private.ConnectionThreadInner(account, targetCharacter)
private.ConnectionThreadDone(account)
end
function private.ConnectionThreadDone(account)
Log.Info("Connection ended to %s", account)
local player = private.connectedCharacter[account]
private.connectedCharacter[account] = nil
if player then
for _, callback in ipairs(private.connectionChangedCallbacks) do
callback(account, player, false)
end
end
private.threadRunning[account] = nil
private.connectionRequestReceived[account] = nil
end
-- ============================================================================
-- Helper Functions
-- ============================================================================
function private.SendNewAccountWhoAmI()
if not private.newCharacter then
Delay.Cancel("syncNewAccount")
elseif not C_FriendList.GetFriendInfo(private.newCharacter) then
Log.Info("Waiting for friends list to update")
elseif not private.IsOnline(private.newCharacter) then
Delay.Cancel("syncNewAccount")
private.newCharacter = nil
private.newAccount = nil
private.newSyncAcked = nil
Log.Err("New player went offline")
else
Comm.SendData(Constants.DATA_TYPES.WHOAMI_ACCOUNT, private.newCharacter)
Log.Info("Sent WHOAMI_ACCOUNT")
end
end
function private.CheckNewAccountStatus()
if not private.newCharacter or not private.newAccount or not private.newSyncAcked then
return
end
Log.Info("New sync character: '%s' '%s'", private.newCharacter, private.newAccount)
-- the other account ACK'd so setup a connection
Settings.NewSyncCharacter(private.newAccount, private.newCharacter)
-- call the callbacks for this new account
for _, callback in ipairs(private.connectionChangedCallbacks) do
callback(private.newAccount, private.newCharacter, nil)
end
private.newCharacter = nil
private.newAccount = nil
private.newSyncAcked = nil
end
function private.GetTargetCharacter(account)
local tempTbl = TempTable.Acquire()
for _, character in Settings.CharacterByAccountFactionrealmIterator(account) do
tinsert(tempTbl, character)
end
-- find the player to connect to without adding to the friends list
for _, player in ipairs(tempTbl) do
if private.IsOnline(player, true) then
TempTable.Release(tempTbl)
return player
end
end
-- if we failed, try again with adding to friends list
for _, player in ipairs(tempTbl) do
if private.IsOnline(player) then
TempTable.Release(tempTbl)
return player
end
end
TempTable.Release(tempTbl)
end
function private.IsOnline(target, noAdd)
local info = C_FriendList.GetFriendInfo(target)
if not info and not noAdd and not private.invalidCharacters[strlower(target)] and C_FriendList.GetNumFriends() ~= 50 then
-- add them as a friend
C_FriendList.AddFriend(target)
private.RequestFriendsInfo()
tinsert(private.addedFriends, target)
info = C_FriendList.GetFriendInfo(target)
end
return info and info.connected or false
end
function private.ChatMsgSystemEventHandler(_, msg)
if #private.addedFriends == 0 then
return
end
if msg == ERR_FRIEND_NOT_FOUND then
if #private.addedFriends > 0 then
private.invalidCharacters[strlower(tremove(private.addedFriends, 1))] = true
end
else
for i, v in ipairs(private.addedFriends) do
if format(ERR_FRIEND_ADDED_S, v) == msg then
tremove(private.addedFriends, i)
private.invalidCharacters[strlower(v)] = true
end
end
end
end

View File

@@ -0,0 +1,29 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Constants = TSM.Init("Service.SyncClasses.Constants")
Constants.VERSION = 11
Constants.DATA_TYPES = {
-- new connection types (40-49)
WHOAMI_ACCOUNT = strchar(40),
WHOAMI_ACK = strchar(41),
-- connection status types (50-69)
CONNECTION_REQUEST = strchar(50),
CONNECTION_REQUEST_ACK = strchar(51),
DISCONNECT = strchar(52),
HEARTBEAT = strchar(53),
-- data mirroring types (70-99)
CHARACTER_HASHES_BROADCAST = strchar(70),
CHARACTER_SETTING_HASHES_REQUEST = strchar(71),
CHARACTER_SETTING_HASHES_RESPONSE = strchar(72),
CHARACTER_SETTING_DATA_REQUEST = strchar(73),
CHARACTER_SETTING_DATA_RESPONSE = strchar(74),
-- RPC types (100-109)
RPC_CALL = strchar(100),
RPC_RETURN = strchar(101),
RPC_PREAMBLE = strchar(102),
}

View File

@@ -0,0 +1,268 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local Mirror = TSM.Init("Service.SyncClasses.Mirror")
local Delay = TSM.Include("Util.Delay")
local TempTable = TSM.Include("Util.TempTable")
local Math = TSM.Include("Util.Math")
local Log = TSM.Include("Util.Log")
local Settings = TSM.Include("Service.Settings")
local Constants = TSM.Include("Service.SyncClasses.Constants")
local Comm = TSM.Include("Service.SyncClasses.Comm")
local Connection = TSM.Include("Service.SyncClasses.Connection")
local private = {
numConnected = 0,
accountStatus = {},
callbacks = {},
}
local BROADCAST_INTERVAL = 3
-- ============================================================================
-- Module Loading
-- ============================================================================
Mirror:OnModuleLoad(function()
Connection.RegisterConnectionChangedCallback(private.ConnectionChangedHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CHARACTER_HASHES_BROADCAST, private.CharacterHashesBroadcastHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_REQUEST, private.CharacterSettingHashesRequestHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_RESPONSE, private.CharacterSettingHashesResponseHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CHARACTER_SETTING_DATA_REQUEST, private.CharacterSettingDataRequestHandler)
Comm.RegisterHandler(Constants.DATA_TYPES.CHARACTER_SETTING_DATA_RESPONSE, private.CharacterSettingDataResponseHandler)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function Mirror.GetStatus(account)
local status = private.accountStatus[account]
if not status then
return false, false
elseif status == "UPDATING" then
return true, false
elseif status == "SYNCED" then
return true, true
else
error("Invalid status: "..tostring(status))
end
end
function Mirror.RegisterCallback(callback)
tinsert(private.callbacks, callback)
end
-- ============================================================================
-- Connection Callback Handlers
-- ============================================================================
function private.ConnectionChangedHandler(account, player, connected)
if connected == nil then
-- new account, but not yet connected
return
end
if connected then
Log.Info("Connected to %s (%s)", account, player)
else
Log.Info("Disconnected from %s (%s)", account, player)
end
private.numConnected = private.numConnected + (connected and 1 or -1)
assert(private.numConnected >= 0)
if connected then
private.accountStatus[account] = "UPDATING"
Delay.AfterTime("mirrorCharacterHashes", 0, private.SendCharacterHashes, BROADCAST_INTERVAL)
else
private.accountStatus[account] = nil
if private.numConnected == 0 then
Delay.Cancel("mirrorCharacterHashes")
end
end
end
-- ============================================================================
-- Delay-Based Last Update Send Function
-- ============================================================================
function private.SendCharacterHashes()
assert(private.numConnected > 0)
-- calculate the hashes of the sync settings for all characters on this account
local hashes = TempTable.Acquire()
for _, character in Settings.CharacterByAccountFactionrealmIterator() do
hashes[character] = private.CalculateCharacterHash(character)
end
-- send the hashes to all connected accounts
for _, character in Connection.ConnectedAccountIterator() do
Comm.SendData(Constants.DATA_TYPES.CHARACTER_HASHES_BROADCAST, character, hashes)
end
TempTable.Release(hashes)
end
-- ============================================================================
-- Message Handlers
-- ============================================================================
function private.CharacterHashesBroadcastHandler(dataType, sourceAccount, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.CHARACTER_HASHES_BROADCAST)
if not Connection.IsCharacterConnected(sourcePlayer) then
-- we're not connected to this player
Log.Warn("Got CHARACTER_HASHES_BROADCAST for player which isn't connected")
return
end
local didChange = false
for _, character in Settings.CharacterByAccountFactionrealmIterator(sourceAccount) do
if not data[character] then
-- this character doesn't exist anymore, so remove it
Log.Info("Removed character: '%s'", character)
Settings.RemoveSyncCharacter(character)
didChange = true
end
end
for character, hash in pairs(data) do
if not Settings.GetCharacterSyncAccountKey(character) then
-- this is a new character, so add it to our DB
Log.Info("New character: '%s' '%s'", character, sourceAccount)
Settings.NewSyncCharacter(sourceAccount, character)
didChange = true
end
if hash ~= private.CalculateCharacterHash(character) then
-- this character's data has changed so request a hash of each of the keys
Log.Info("Character data has changed: '%s'", character)
Comm.SendData(Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_REQUEST, sourcePlayer, character)
didChange = true
end
end
if didChange then
private.accountStatus[sourceAccount] = "UPDATING"
else
private.accountStatus[sourceAccount] = "SYNCED"
end
end
function private.CharacterSettingHashesRequestHandler(dataType, sourceAccount, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_REQUEST)
if not Connection.IsCharacterConnected(sourcePlayer) then
-- we're not connected to this player
Log.Warn("Got CHARACTER_HASHES_BROADCAST for player which isn't connected")
return
elseif Settings.GetCharacterSyncAccountKey(data) ~= Settings.GetCurrentSyncAccountKey() then
-- we don't own this character
Log.Err("Request for character we don't own ('%s', '%s')", tostring(data), tostring(Settings.GetCharacterSyncAccountKey(data)))
return
end
Log.Info("CHARACTER_SETTING_HASHES_REQUEST (%s)", data)
local responseData = TempTable.Acquire()
responseData._character = data
for _, namespace, settingKey in Settings.SyncSettingIterator() do
responseData[namespace.."."..settingKey] = private.CalculateCharacterSettingHash(data, namespace, settingKey)
end
Comm.SendData(Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_RESPONSE, sourcePlayer, responseData)
TempTable.Release(responseData)
end
function private.CharacterSettingHashesResponseHandler(dataType, sourceAccount, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.CHARACTER_SETTING_HASHES_RESPONSE)
if not Connection.IsCharacterConnected(sourcePlayer) then
-- we're not connected to this player
Log.Warn("Got CHARACTER_HASHES_BROADCAST for player which isn't connected")
return
end
local character = data._character
data._character = nil
Log.Info("CHARACTER_SETTING_HASHES_RESPONSE (%s)", character)
for key, hash in pairs(data) do
local namespace, settingKey = strsplit(".", key)
if private.CalculateCharacterSettingHash(character, namespace, settingKey) ~= hash then
-- the settings data for key changed, so request the latest data for it
Log.Info("Setting data has changed: '%s', '%s'", character, key)
Comm.SendData(Constants.DATA_TYPES.CHARACTER_SETTING_DATA_REQUEST, sourcePlayer, character.."."..key)
end
end
end
function private.CharacterSettingDataRequestHandler(dataType, sourceAccount, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.CHARACTER_SETTING_DATA_REQUEST)
local character, namespace, settingKey = strsplit(".", data)
if not Connection.IsCharacterConnected(sourcePlayer) then
-- we're not connected to this player
Log.Warn("Got CHARACTER_HASHES_BROADCAST for player which isn't connected")
return
elseif Settings.GetCharacterSyncAccountKey(character) ~= Settings.GetCurrentSyncAccountKey() then
-- we don't own this character
Log.Err("Request for character we don't own ('%s', '%s')", tostring(character), tostring(Settings.GetCharacterSyncAccountKey(character)))
return
end
Log.Info("CHARACTER_SETTING_DATA_REQUEST (%s,%s,%s)", character, namespace, settingKey)
local responseData = TempTable.Acquire()
responseData.character = character
responseData.namespace = namespace
responseData.settingKey = settingKey
responseData.data = Settings.Get("sync", Settings.GetSyncScopeKeyByCharacter(character), namespace, settingKey)
Comm.SendData(Constants.DATA_TYPES.CHARACTER_SETTING_DATA_RESPONSE, sourcePlayer, responseData)
TempTable.Release(responseData)
end
function private.CharacterSettingDataResponseHandler(dataType, sourceAccount, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.CHARACTER_SETTING_DATA_RESPONSE)
if not Connection.IsCharacterConnected(sourcePlayer) then
-- we're not connected to this player
Log.Warn("Got CHARACTER_HASHES_BROADCAST for player which isn't connected")
return
end
local dataValueType = type(data.data)
Log.Info("CHARACTER_SETTING_DATA_RESPONSE (%s,%s,%s,%s,%s)", data.character, data.namespace, data.settingKey, dataValueType, (dataValueType == "string" or dataValueType == "table") and #dataValueType or "-")
if dataValueType == "table" then
local tbl = Settings.Get("sync", Settings.GetSyncScopeKeyByCharacter(data.character), data.namespace, data.settingKey)
wipe(tbl)
for i, v in pairs(data.data) do
tbl[i] = v
end
else
Settings.Set("sync", Settings.GetSyncScopeKeyByCharacter(data.character), data.namespace, data.settingKey, data.data)
end
for _, callback in ipairs(private.callbacks) do
callback()
end
end
-- ============================================================================
-- Helper Functions
-- ============================================================================
function private.CalculateCharacterHash(character)
local hash = nil
local settingKeys = TempTable.Acquire()
for _, namespace, settingKey in Settings.SyncSettingIterator() do
tinsert(settingKeys, strjoin(".", namespace, settingKey))
end
sort(settingKeys)
for _, key in ipairs(settingKeys) do
hash = Math.CalculateHash(key, hash)
local namespace, settingKey = strsplit(".", key)
local settingValue = Settings.Get("sync", Settings.GetSyncScopeKeyByCharacter(character), namespace, settingKey)
hash = Math.CalculateHash(settingValue, hash)
end
assert(hash)
TempTable.Release(settingKeys)
return hash
end
function private.CalculateCharacterSettingHash(character, namespace, settingKey)
return Math.CalculateHash(Settings.Get("sync", Settings.GetSyncScopeKeyByCharacter(character), namespace, settingKey))
end

View File

@@ -0,0 +1,177 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
local _, TSM = ...
local RPC = TSM.Init("Service.SyncClasses.RPC")
local Delay = TSM.Include("Util.Delay")
local TempTable = TSM.Include("Util.TempTable")
local Log = TSM.Include("Util.Log")
local Constants = TSM.Include("Service.SyncClasses.Constants")
local Comm = TSM.Include("Service.SyncClasses.Comm")
local Connection = TSM.Include("Service.SyncClasses.Connection")
local private = {
rpcFunctions = {},
pendingRPC = {},
rpcSeqNum = 0,
}
local RPC_EXTRA_TIMEOUT = 15
local CALLBACK_TIME_WARNING_THRESHOLD_MS = 20
-- ============================================================================
-- Module Loading
-- ============================================================================
RPC:OnModuleLoad(function()
Comm.RegisterHandler(Constants.DATA_TYPES.RPC_CALL, private.HandleCall)
Comm.RegisterHandler(Constants.DATA_TYPES.RPC_RETURN, private.HandleReturn)
Comm.RegisterHandler(Constants.DATA_TYPES.RPC_PREAMBLE, private.HandlePreamble)
end)
-- ============================================================================
-- Module Functions
-- ============================================================================
function RPC.Register(name, func)
assert(name)
private.rpcFunctions[name] = func
end
function RPC.Call(name, targetPlayer, handler, ...)
assert(targetPlayer)
if not Connection.IsCharacterConnected(targetPlayer) then
return false
end
assert(private.rpcFunctions[name], "Cannot call an RPC which is not also registered locally.")
private.rpcSeqNum = private.rpcSeqNum + 1
local requestData = TempTable.Acquire()
requestData.name = name
requestData.args = TempTable.Acquire(...)
requestData.seq = private.rpcSeqNum
local numBytes = Comm.SendData(Constants.DATA_TYPES.RPC_CALL, targetPlayer, requestData)
TempTable.Release(requestData.args)
TempTable.Release(requestData)
local context = TempTable.Acquire()
context.name = name
context.handler = handler
context.timeoutTime = time() + RPC_EXTRA_TIMEOUT + private.EstimateTransferTime(numBytes)
private.pendingRPC[private.rpcSeqNum] = context
Delay.AfterTime("SYNC_PENDING_RPC", 1, private.HandlePendingRPC)
return true, (context.timeoutTime - time()) * 2 / 3
end
function RPC.Cancel(name, handler)
for seq, info in pairs(private.pendingRPC) do
if info.name == name and info.handler == handler then
TempTable.Release(info)
private.pendingRPC[seq] = nil
return
end
end
end
-- ============================================================================
-- Message Handlers
-- ============================================================================
function private.HandleCall(dataType, _, sourcePlayer, data)
assert(dataType == Constants.DATA_TYPES.RPC_CALL)
if type(data) ~= "table" or type(data.name) ~= "string" or type(data.seq) ~= "number" or type(data.args) ~= "table" then
return
end
if not private.rpcFunctions[data.name] then
return
end
local responseData = TempTable.Acquire()
local startTime = debugprofilestop()
responseData.result = TempTable.Acquire(private.rpcFunctions[data.name](unpack(data.args)))
local timeTaken = debugprofilestop() - startTime
if timeTaken > CALLBACK_TIME_WARNING_THRESHOLD_MS then
Log.Warn("RPC (%s) took %0.2fms", tostring(data.name), timeTaken)
end
responseData.seq = data.seq
local numBytes = Comm.SendData(Constants.DATA_TYPES.RPC_RETURN, sourcePlayer, responseData)
TempTable.Release(responseData.result)
TempTable.Release(responseData)
local transferTime = private.EstimateTransferTime(numBytes)
if transferTime > 1 then
-- We sent more than 1 second worth of data back, so send a preamble to allow the source to adjust its timeout accordingly.
local preambleData = TempTable.Acquire()
preambleData.transferTime = transferTime
preambleData.seq = data.seq
Comm.SendData(Constants.DATA_TYPES.RPC_PREAMBLE, sourcePlayer, preambleData)
TempTable.Release(preambleData)
end
end
function private.HandleReturn(dataType, _, _, data)
assert(dataType == Constants.DATA_TYPES.RPC_RETURN)
if type(data.seq) ~= "number" or type(data.result) ~= "table" then
return
elseif not private.pendingRPC[data.seq] then
return
end
local startTime = debugprofilestop()
private.pendingRPC[data.seq].handler(unpack(data.result))
local timeTaken = debugprofilestop() - startTime
if timeTaken > CALLBACK_TIME_WARNING_THRESHOLD_MS then
Log.Warn("RPC (%s) result handler took %0.2fms", tostring(private.pendingRPC[data.seq].name), timeTaken)
end
TempTable.Release(private.pendingRPC[data.seq])
private.pendingRPC[data.seq] = nil
end
function private.HandlePreamble(dataType, _, _, data)
assert(dataType == Constants.DATA_TYPES.RPC_PREAMBLE)
if type(data.seq) ~= "number" or type(data.transferTime) ~= "number" then
return
elseif not private.pendingRPC[data.seq] then
return
end
-- extend the timeout
private.pendingRPC[data.seq].timeoutTime = time() + RPC_EXTRA_TIMEOUT + data.transferTime
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.EstimateTransferTime(numBytes)
return ceil(numBytes / (ChatThrottleLib.MAX_CPS / 2))
end
function private.HandlePendingRPC()
if not next(private.pendingRPC) then
return
end
local timedOut = TempTable.Acquire()
for seq, info in pairs(private.pendingRPC) do
if time() > info.timeoutTime then
tinsert(timedOut, seq)
end
end
for _, seq in ipairs(timedOut) do
local info = private.pendingRPC[seq]
Log.Warn("RPC timed out (%s)", info.name)
info.handler()
TempTable.Release(info)
private.pendingRPC[seq] = nil
end
TempTable.Release(timedOut)
end

View File

@@ -0,0 +1,839 @@
-- ------------------------------------------------------------------------------ --
-- TradeSkillMaster --
-- https://tradeskillmaster.com --
-- All Rights Reserved - Detailed license information included with addon. --
-- ------------------------------------------------------------------------------ --
--- Threading Functions
-- @module Threading
local _, TSM = ...
local Threading = TSM.Init("Service.Threading")
local Debug = TSM.Include("Util.Debug")
local Math = TSM.Include("Util.Math")
local TempTable = TSM.Include("Util.TempTable")
local Vararg = TSM.Include("Util.Vararg")
local Log = TSM.Include("Util.Log")
local ErrorHandler = TSM.Include("Service.ErrorHandler")
local Thread = TSM.Include("LibTSMClass").DefineClass("Thread")
local private = {
threads = {},
queue = {},
runningThread = nil,
schedulerFrame = nil,
}
local MAX_TIME_USAGE_RATIO = 0.25
local EXCESSIVE_TIME_USED_RATIO = 1.2
local EXCESSIVE_TIME_LOG_THRESHOLD_MS = 100
local MAX_QUANTUM_MS = 10
local SEND_MSG_SYNC_TIMEOUT_MS = 3000
local YIELD_VALUE_START = {}
local YIELD_VALUE = {}
local SCHEDULER_TIME_WARNING_THRESHOLD_MS = 100
-- ============================================================================
-- Module Functions
-- ============================================================================
--- Create a new thread.
-- @tparam string name The name of the thread (for debugging purposes)
-- @tparam function func The thread's main function
-- @treturn string The thread id
function Threading.New(name, func)
assert(name and func)
local thread = Thread(name, func)
local threadId = strjoin("-", tostringall(thread, func))
private.threads[threadId] = thread
return threadId
end
--- Set the callback for a thread.
-- The callback is called when a thread finishes running and is passed all values returned by the thread's main function.
-- @tparam string threadId The thread id
-- @tparam function callback The callback function
function Threading.SetCallback(threadId, callback)
private.threads[threadId]:_SetCallback(callback)
end
--- Start a thread.
-- The thread will not actually be run until the next run of the scheduler (next frame).
-- @tparam string threadId The thread id
-- @tparam vararg ... Arguments to pass to the thread's main function
function Threading.Start(threadId, ...)
local thread = private.threads[threadId]
assert(not thread:_IsAlive())
-- make sure the scheduler is running
private.StartScheduler()
thread:_Start(...)
end
--- Send a message to a thread.
-- @tparam string threadId The thread id
-- @tparam vararg ... The contents of the message
function Threading.SendMessage(threadId, ...)
local thread = private.threads[threadId]
assert(thread:_IsAlive())
tinsert(thread._messages, TempTable.Acquire(...))
end
--- Send a synchronous message to a thread.
-- The current execution context will be blocked until the message is delivered.
-- @tparam string threadId The thread id
-- @tparam vararg ... The contents of the message
function Threading.SendSyncMessage(threadId, ...)
if Threading.IsThreadContext() then
-- we can't (sanely) run a thread from within a thread context, so we'll yield from the current thread first
private.runningThread:_SendSyncMessage(threadId, ...)
else
local errMsg = private.threads[threadId]:_HandleSyncMessage(...)
if errMsg then
error(errMsg)
end
end
end
--- Kill a thread.
-- @tparam string threadId The thread id
function Threading.Kill(threadId)
local thread = private.threads[threadId]
if thread:_IsAlive() then
thread:_Exit()
end
end
--- Check if we're currently in a thread context.
-- @treturn boolean Whether or not we're currently in a thread context
function Threading.IsThreadContext()
return private.runningThread ~= nil
end
--- Get the name of the current thread.
-- @treturn ?string The name of the currently-running thread or nil if no thread is running
function Threading.GetCurrentThreadName()
return private.runningThread and private.runningThread._name or nil
end
--- Check if the current thread has any pending messages.
-- This must be called from a thread context.
-- @treturn boolean Whether or not this thread has any pending messages
function Threading.HasPendingMessage()
return private.runningThread:_HasPendingMessage()
end
--- Receive the next message.
-- This must be called from a thread context.
-- @return The context of the message
function Threading.ReceiveMessage()
return private.runningThread:_ReceiveMessage()
end
--- Performs a yield.
-- This must be called from a thread context. This function should be called regularly by threads to allow them the
-- scheduler to switch to another thread if this thread's quantum is up. If this thread has not exceeded its quantum,
-- this function will return right away.
-- @tparam[opt=false] boolean force If true, forces this thread to yield, regardless of whether or not it needs to
function Threading.Yield(force)
private.runningThread:_Yield(force)
end
--- Sleep the thread.
-- This must be called from a thread context.
-- @tparam number seconds The number of seconds to sleep for (may be a deciaml number)
function Threading.Sleep(seconds)
private.runningThread:_Sleep(seconds)
end
--- Wait for a WoW event.
-- This must be called from a thread context.
-- @tparam string event The WoW event to wait for
function Threading.WaitForEvent(...)
return private.runningThread:_WaitForEvent(...)
end
--- Wait for a function.
-- This must be called from a thread context. It will block the thread until the specified function returns a true value.
-- @tparam function func The function to wait for
-- @tparam vararg ... Additional arguments to pass to the function
function Threading.WaitForFunction(func, ...)
return private.runningThread:_WaitForFunction(func, ...)
end
--- Wait for a future.
-- This must be called from a thread context. It will block the thread until the specified future is done.
-- @tparam Future future The future to wait for
function Threading.WaitForFuture(future)
return private.runningThread:_WaitForFuture(future)
end
--- Acquire a temp table.
-- This must be called from a thread context. Any time a thread needs to maintain a temp table across a potential yield,
-- it should use this API. This API will release the temp tables in the case that the thread gets killed.
-- @see TempTable.Acquire
-- @tparam vararg ... Values to insert into the temp table
function Threading.AcquireSafeTempTable(...)
return private.runningThread:_AcquireSafeTempTable(...)
end
--- Release a temp table.
-- This must be called from a thread context. This is used to release temp tables acquired with
-- @{Threading.AcquireSafeTempTable}.
-- @see TempTable.Release
-- @tparam table tbl The temp table to release
function Threading.ReleaseSafeTempTable(tbl)
return private.runningThread:_ReleaseSafeTempTable(tbl)
end
--- Release a temp table and returns its contents.
-- This must be called from a thread context. This is used to release and unpack temp tables acquired with
-- @{Threading.AcquireSafeTempTable}.
-- @see TempTable.UnpackAndRelease
-- @tparam table tbl The temp table to release and unpack
-- @return The contents of the temp table
function Threading.UnpackAndReleaseSafeTempTable(tbl)
return private.runningThread:_UnpackAndReleaseSafeTempTable(tbl)
end
--- Assign ownership of a database query to the thread so that if the thread is killed it'll get released safely.
-- This must be called from a thread context.
-- @tparam DatabaseQuery query The query object
function Threading.GuardDatabaseQuery(query)
return private.runningThread:_GuardDatabaseQuery(query)
end
--- Removes ownership of a database query from the thread.
-- This must be called from a thread context.
-- @tparam DatabaseQuery query The query object
function Threading.UnguardDatabaseQuery(query)
return private.runningThread:_UnguardDatabaseQuery(query)
end
function Threading.GetDebugStr()
local lines = {}
for _, thread in pairs(private.threads) do
if thread:_IsAlive() then
local name, stateStr, timeStr, createStr, startStr, backtrace = thread:_GetDebugInfo()
tinsert(lines, " "..name)
tinsert(lines, " "..stateStr)
tinsert(lines, " "..timeStr)
tinsert(lines, " "..createStr)
tinsert(lines, " "..startStr)
if #backtrace > 0 then
tinsert(lines, " Backtrace:")
for _, line in ipairs(backtrace) do
tinsert(lines, " "..line)
end
end
end
end
return table.concat(lines, "\n")
end
-- ============================================================================
-- Thread Class - General Methods
-- ============================================================================
function Thread.__init(self, name, func)
-- core fields
self._func = func
self._co = nil
self._state = "DEAD"
self._endTime = nil
self._sleepTime = nil
self._eventNames = {}
self._eventArgs = nil
self._waitFunction = nil
self._waitFunctionArgs = nil
self._waitFunctionResult = nil
self._waitFuture = nil
self._waitFutureResult = nil
self._waitFutureDone = false
self._syncMessage = nil
self._syncMessageDest = nil
self._messages = {}
self._callback = nil
self._returnValue = nil
self._safeTempTables = {}
self._guardedQueries = {}
-- debug fields
self._startTime = 0
self._cpuTimeUsed = 0
self._realTimeUsed = 0
self._name = name or tostring(self)
self._createCaller = Debug.GetStackLevelLocation(3) or "?"
self._startCaller = nil
end
function Thread._Start(self, ...)
self._co = coroutine.create(self._Main)
self._state = "READY"
self._endTime = 0
self._sleepTime = nil
wipe(self._eventNames)
self._eventArgs = nil
self._waitFunction = nil
self._waitFunctionArgs = nil
self._waitFunctionResult = nil
self._waitFuture = nil
self._waitFutureResult = nil
self._waitFutureDone = false
self._syncMessage = nil
self._syncMessageDest = nil
assert(not next(self._messages))
assert(not next(self._safeTempTables))
assert(not next(self._guardedQueries))
self._startTime = 0
self._cpuTimeUsed = 0
self._realTimeUsed = 0
self._startCaller = self._startCaller or Debug.GetStackLevelLocation(3)
-- run the thread once (will yield right away) to pass in self and the arguments
local noErr, retValue = coroutine.resume(self._co, self, ...)
assert(noErr and retValue == YIELD_VALUE_START)
end
function Thread._SetCallback(self, callback)
self._callback = callback
end
function Thread._IsAlive(self)
return self._state ~= "DEAD"
end
function Thread._ToLogStr(self)
if self._startTime then
self._realTimeUsed = debugprofilestop() - self._startTime
local pctStr = format("%.1f%%", Math.Round(self._cpuTimeUsed / self._realTimeUsed, 0.001) * 100)
return format("%s [%s,%s]", self._name, self._state, pctStr)
else
return format("%s [%s]", self._name, self._state)
end
end
function Thread._Cleanup(self)
for _, msg in ipairs(self._messages) do
TempTable.Release(msg)
end
wipe(self._messages)
for tbl in pairs(self._safeTempTables) do
TempTable.Release(tbl)
end
wipe(self._safeTempTables)
for query in pairs(self._guardedQueries) do
query:Release(true)
end
wipe(self._guardedQueries)
self._waitFunction = nil
if self._waitFunctionArgs then
TempTable.Release(self._waitFunctionArgs)
self._waitFunctionArgs = nil
end
if self._waitFuture then
self._waitFuture:Cancel()
self._waitFuture = nil
self._waitFutureResult = nil
self._waitFutureDone = false
end
if self._waitFunctionResult then
TempTable.Release(self._waitFunctionResult)
self._waitFunctionResult = nil
end
if self._syncMessage then
TempTable.Release(self._syncMessage)
self._syncMessage = nil
self._syncMessageDest = nil
end
end
function Thread._GetDebugInfo(self)
local stateStr = nil
if self._state == "SLEEPING" then
stateStr = format("Sleeping for %d seconds", Math.Round(self._sleepTime, 0.001))
elseif self._state == "WAITING_FOR_MSG" then
if #self._messages > 0 then
stateStr = "Got message"
else
stateStr = "Waiting for message"
end
elseif self._state == "WAITING_FOR_EVENT" then
if next(self._eventNames) then
local eventList = {}
for event in pairs(self._eventNames) do
tinsert(eventList, event)
end
stateStr = format("Waiting for %s", table.concat(eventList, "|"))
else
stateStr = format("Got %s", self._eventArgs[1])
end
elseif self._state == "WAITING_FOR_FUNCTION" then
local functionName = nil
-- look up to 2 levels deep in the globals table for the name of this function
for k, v in pairs(_G) do
if type(v) == "table" then
for k2, v2 in pairs(v) do
if v2 == self._waitFunction then
functionName = tostring(k).."."..tostring(k2)
break
end
end
if functionName then
break
end
elseif v == self._waitFunction then
functionName = tostring(k)
break
end
end
stateStr = format("Waiting for %s", functionName or tostring(self._waitFunction))
elseif self._state == "WAITING_FOR_FUTURE" then
stateStr = format("Waiting for future (%s)", self._waitFuture:GetName())
elseif self._state == "FORCED_YIELD" then
stateStr = "Forced yield"
elseif self._state == "SENDING_SYNC_MESSAGE" then
stateStr = format("Sending sync message to %s", self._syncMessageDest and private.threads[self._syncMessageDest]._name or "?")
elseif self._state == "RUNNING" then
stateStr = "Running"
elseif self._state == "DEAD" then
stateStr = "Dead"
elseif self._state == "READY" then
stateStr = "Ready"
else
error("Invalid thread state: "..tostring(self._state))
end
if #self._messages > 0 then
stateStr = format("%s (%d messages)", stateStr, #self._messages)
end
local timeStr = "<Not Started>"
if self._startTime then
local wallTime = debugprofilestop() - self._startTime
local cpuTime = self._cpuTimeUsed
timeStr = format("Running for %.1f seconds (CPU: %dms, %.2f%%)", wallTime / 1000, cpuTime, (cpuTime / wallTime) * 100)
end
local createStr = "Created @"..self._createCaller
local startStr = "Started @"..(self._startCaller or "<Not Started>")
local backtrace = {}
local level = 2
local line = Debug.GetStackLevelLocation(level, self._co)
while line do
tinsert(backtrace, line)
level = level + 1
line = Debug.GetStackLevelLocation(level, self._co)
end
return self._name, stateStr, timeStr, createStr, startStr, backtrace
end
-- ============================================================================
-- Thread Class - Scheduler Helper Methods
-- ============================================================================
function Thread._CanRun(self)
return self._state == "READY"
end
function Thread._Run(self, quantum)
assert(not Threading.IsThreadContext())
if not self:_CanRun() then
return 0
end
private.runningThread = self
self._state = "RUNNING"
local startTime = debugprofilestop()
self._endTime = startTime + quantum
local noErr, returnVal = coroutine.resume(self._co)
local elapsedTime = debugprofilestop() - startTime
private.runningThread = nil
assert(not noErr or returnVal == YIELD_VALUE)
if noErr and self._state == "SENDING_SYNC_MESSAGE" then
-- yielded to send a sync message to another thread
local destThread = private.threads[self._syncMessageDest]
local msg = self._syncMessage
self._syncMessage = nil
self._syncMessageDest = nil
local errMsg = destThread:_HandleSyncMessage(TempTable.UnpackAndRelease(msg))
if errMsg then
noErr = false
returnVal = errMsg
elseif self._state == "SENDING_SYNC_MESSAGE" then
self._state = "READY"
end
end
if not noErr then
returnVal = returnVal or "UNKNOWN ERROR"
ErrorHandler.ShowForThread(returnVal, self._co)
self._state = "DEAD"
end
if self._state == "DEAD" then
self:_Cleanup()
local returnValue = self._returnValue
self._returnValue = nil
if self._callback and returnValue then
self._callback(TempTable.UnpackAndRelease(returnValue))
elseif returnValue then
TempTable.Release(returnValue)
end
end
return elapsedTime
end
function Thread._UpdateState(self, elapsed)
-- check what the thread state is
if self._state == "SLEEPING" then
self._sleepTime = self._sleepTime - elapsed
if self._sleepTime <= 0 then
self._sleepTime = nil
self._state = "READY"
end
elseif self._state == "WAITING_FOR_MSG" then
if #self._messages > 0 then
self._state = "READY"
end
elseif self._state == "WAITING_FOR_EVENT" then
assert(self._eventNames or self._eventArgs)
if self._eventArgs then
self._state = "READY"
end
elseif self._state == "WAITING_FOR_FUNCTION" then
assert(self._waitFunction, "Waiting for function without waitFunction set")
local result = TempTable.Acquire(self._waitFunction(unpack(self._waitFunctionArgs)))
if result[1] then
self._waitFunctionResult = result
self._state = "READY"
else
TempTable.Release(result)
end
elseif self._state == "WAITING_FOR_FUTURE" then
if self._waitFutureDone then
assert(not self._waitFuture)
self._waitFutureDone = false
self._state = "READY"
else
assert(self._waitFuture)
end
elseif self._state == "FORCED_YIELD" then
self._state = "READY"
elseif self._state == "RUNNING" then
-- this shouldn't happen, so just kill this thread
self:_Exit()
elseif self._state == "DEAD" then
-- pass
elseif self._state == "READY" then
-- pass
else
error("Invalid thread state: "..tostring(self._state))
end
end
function Thread._ProcessEvent(self, event, ...)
if self._state ~= "WAITING_FOR_EVENT" then
return
end
assert(next(self._eventNames) or self._eventArgs)
if not self._eventNames[event] then
return
end
wipe(self._eventNames) -- only trigger the event once then clear all
self._eventArgs = TempTable.Acquire(event, ...)
end
function Thread._OnFutureDone(self, future)
if self._state ~= "WAITING_FOR_FUTURE" then
return
end
assert(self._waitFuture or self._waitFutureDone)
if future ~= self._waitFuture then
return
end
self._waitFutureDone = true
self._waitFuture = nil
self._waitFutureResult = future:GetValue()
self:_UpdateState(0)
self:_Run(0)
end
function Thread._HandleSyncMessage(self, ...)
assert(not Threading.IsThreadContext())
local msg = TempTable.Acquire(...)
tinsert(self._messages, 1, msg) -- this message should be received first
-- run the thread for up to 3 seconds to get it to process the sync message
local startTime = debugprofilestop()
while self._messages[1] == msg do
if debugprofilestop() - startTime > SEND_MSG_SYNC_TIMEOUT_MS or not self:_IsAlive() then
-- want to error from the sending context, so just return the error
return format("ERROR: A sync message was not able to be delivered! (%s)", self._name)
end
assert(self._state ~= "SENDING_SYNC_MESSAGE", "Circular sync message detected")
if self._state == "WAITING_FOR_MSG" then
self._state = "READY"
end
self:_Run(0)
end
end
-- ============================================================================
-- Thread Class - Thread Context Methods
-- ============================================================================
function Thread._Main(self, ...)
self._startTime = debugprofilestop()
coroutine.yield(YIELD_VALUE_START)
self._returnValue = TempTable.Acquire(self._func(...))
self:_Exit()
end
function Thread._Yield(self, force)
if force or self._state ~= "RUNNING" or debugprofilestop() > self._endTime then
-- only change the state if it's currently set to RUNNING
if self._state == "RUNNING" then
self._state = force and "FORCED_YIELD" or "READY"
end
coroutine.yield(YIELD_VALUE)
end
end
function Thread._Sleep(self, seconds)
self._state = "SLEEPING"
self._sleepTime = seconds
self:_Yield()
end
function Thread._HasPendingMessage(self)
return #self._messages > 0
end
function Thread._ReceiveMessage(self)
if #self._messages == 0 then
-- change the state if there's no messages ready
self._state = "WAITING_FOR_MSG"
elseif debugprofilestop() > self._endTime then
-- If we're about to yield, set the state to WAITING_FOR_MSG even if we have messages in the queue
-- to allow sync messages to be sent to us.
self._state = "WAITING_FOR_MSG"
end
self:_Yield()
return TempTable.UnpackAndRelease(tremove(self._messages, 1))
end
function Thread._SendSyncMessage(self, destThread, ...)
assert(destThread ~= self)
self._state = "SENDING_SYNC_MESSAGE"
self._syncMessageDest = destThread
self._syncMessage = TempTable.Acquire(...)
self:_Yield()
end
function Thread._WaitForEvent(self, ...)
self._state = "WAITING_FOR_EVENT"
self._eventArgs = nil
for _, event in Vararg.Iterator(...) do
self._eventNames[event] = true
private.schedulerFrame:RegisterEvent(event)
end
local st = GetTime()
self:_Yield()
local yieldTime = GetTime() - st
if yieldTime > 5 then
local firstEvent = ...
Log.Warn("Waited %d seconds for %s", yieldTime, firstEvent)
end
local result = self._eventArgs
self._eventArgs = nil
return TempTable.UnpackAndRelease(result)
end
function Thread._WaitForFunction(self, func, ...)
-- try the function once before yielding
local result = TempTable.Acquire(func(...))
if result[1] then
return TempTable.UnpackAndRelease(result)
end
TempTable.Release(result)
-- do the yield
self._state = "WAITING_FOR_FUNCTION"
self._waitFunction = func
self._waitFunctionArgs = TempTable.Acquire(...)
self:_Yield()
result = self._waitFunctionResult
self._waitFunction = nil
TempTable.Release(self._waitFunctionArgs)
self._waitFunctionArgs = nil
self._waitFunctionResult = nil
return TempTable.UnpackAndRelease(result)
end
function Thread._WaitForFuture(self, future)
-- try the future once before yielding
if future:IsDone() then
return future:GetValue()
end
-- register our OnDone handler and do the yield
future:SetScript("OnDone", private.OnFutureDone)
self._state = "WAITING_FOR_FUTURE"
self._waitFuture = future
self:_Yield()
local result = self._waitFutureResult
self._waitFutureResult = nil
return result
end
function Thread._AcquireSafeTempTable(self, ...)
local tbl = TempTable.Acquire(...)
assert(not self._safeTempTables[tbl])
self._safeTempTables[tbl] = true
return tbl
end
function Thread._ReleaseSafeTempTable(self, tbl)
assert(self._safeTempTables[tbl])
self._safeTempTables[tbl] = nil
return TempTable.Release(tbl)
end
function Thread._UnpackAndReleaseSafeTempTable(self, tbl)
assert(self._safeTempTables[tbl])
self._safeTempTables[tbl] = nil
return TempTable.UnpackAndRelease(tbl)
end
function Thread._GuardDatabaseQuery(self, query)
assert(not self._guardedQueries[query])
self._guardedQueries[query] = true
end
function Thread._UnguardDatabaseQuery(self, query)
assert(self._guardedQueries[query])
self._guardedQueries[query] = nil
end
function Thread._Exit(self)
assert(self:_IsAlive())
self._state = "DEAD"
self:_Cleanup()
Log.Info("Thread finished: %s", self:_ToLogStr())
if self == private.runningThread then
coroutine.yield(YIELD_VALUE)
error("Shouldn't get here")
elseif self._returnValue then
TempTable.Release(self._returnValue)
self._returnValue = nil
end
end
-- ============================================================================
-- Private Helper Functions
-- ============================================================================
function private.StartScheduler()
if private.schedulerFrame:IsVisible() then
return
end
Log.Info("Starting scheduler")
private.schedulerFrame:Show()
end
function private.RunScheduler(_, elapsed)
-- don't run any threads while in combat
if InCombatLockdown() then
return
end
local startTime = debugprofilestop()
local numReadyThreads = 0
wipe(private.queue)
-- go through all the threads, update their state, and add the ready ones into the queue
for _, thread in pairs(private.threads) do
thread:_UpdateState(elapsed)
if thread:_CanRun() then
numReadyThreads = numReadyThreads + 1
tinsert(private.queue, thread)
end
end
local remainingTime = min(elapsed * 1000 * MAX_TIME_USAGE_RATIO, MAX_QUANTUM_MS)
while remainingTime > 0.01 do
local ranThread = false
for i = #private.queue, 1, -1 do
local thread = private.queue[i]
if thread:_CanRun() then
local quantum = remainingTime / numReadyThreads
local elapsedTime = thread:_Run(quantum)
thread._cpuTimeUsed = thread._cpuTimeUsed + elapsedTime
remainingTime = remainingTime - min(elapsedTime, quantum)
-- any thread which ran excessively long should be ignored for future loops
if elapsedTime > EXCESSIVE_TIME_USED_RATIO * quantum and elapsedTime > quantum + 1 then
if elapsedTime > EXCESSIVE_TIME_LOG_THRESHOLD_MS then
local line = Debug.GetStackLevelLocation(2, thread._co)
Log.Warn("Thread %s ran too long (%.1f/%.1f): %s", thread._name, elapsedTime, quantum, line or "?")
end
tremove(private.queue, i)
end
ranThread = true
end
end
if not ranThread then
break
end
end
local hasAliveThread = false
for _, thread in pairs(private.threads) do
if thread:_IsAlive() then
hasAliveThread = true
break
end
end
if not hasAliveThread then
Log.Info("Stopping the scheduler")
private.schedulerFrame:Hide()
end
local timeTaken = debugprofilestop() - startTime
if timeTaken > SCHEDULER_TIME_WARNING_THRESHOLD_MS then
Log.Warn("Scheduler took %.2fms", timeTaken)
end
end
function private.ProcessEvent(self, event, ...)
local startTime = debugprofilestop()
for _, thread in pairs(private.threads) do
thread:_ProcessEvent(event, ...)
end
local timeTaken = debugprofilestop() - startTime
if timeTaken > SCHEDULER_TIME_WARNING_THRESHOLD_MS then
Log.Warn("Scheduler took %.2fms to process %s", timeTaken, tostring(event))
end
end
function private.OnFutureDone(future)
for _, thread in pairs(private.threads) do
thread:_OnFutureDone(future)
end
end
-- ============================================================================
-- Driver Frame
-- ============================================================================
do
private.schedulerFrame = CreateFrame("Frame")
private.schedulerFrame:Hide()
private.schedulerFrame:SetScript("OnUpdate", private.RunScheduler)
private.schedulerFrame:SetScript("OnEvent", private.ProcessEvent)
end