-- ------------------------------------------------------------------------------ -- -- TradeSkillMaster -- -- https://tradeskillmaster.com -- -- All Rights Reserved - Detailed license information included with addon. -- -- ------------------------------------------------------------------------------ -- local _, TSM = ... local PostScan = TSM.Auctioning:NewPackage("PostScan") local L = TSM.Include("Locale").GetTable() local Database = TSM.Include("Util.Database") local TempTable = TSM.Include("Util.TempTable") local SlotId = TSM.Include("Util.SlotId") local Delay = TSM.Include("Util.Delay") local Math = TSM.Include("Util.Math") local Log = TSM.Include("Util.Log") local Event = TSM.Include("Util.Event") local ItemString = TSM.Include("Util.ItemString") local Threading = TSM.Include("Service.Threading") local ItemInfo = TSM.Include("Service.ItemInfo") local BagTracking = TSM.Include("Service.BagTracking") local AuctionHouseWrapper = TSM.Include("Service.AuctionHouseWrapper") local private = { scanThreadId = nil, queueDB = nil, nextQueueIndex = 1, bagDB = nil, itemList = {}, operationDB = nil, debugLog = {}, itemLocation = ItemLocation:CreateEmpty(), subRowsTemp = {}, groupsQuery = nil, --luacheck: ignore 1004 - just stored for GC reasons operationsQuery = nil, --luacheck: ignore 1004 - just stored for GC reasons isAHOpen = false, } local RESET_REASON_LOOKUP = { minPrice = "postResetMin", maxPrice = "postResetMax", normalPrice = "postResetNormal" } local ABOVE_MAX_REASON_LOOKUP = { minPrice = "postAboveMaxMin", maxPrice = "postAboveMaxMax", normalPrice = "postAboveMaxNormal", none = "postAboveMaxNoPost" } local MAX_COMMODITY_STACKS_PER_AUCTION = 40 -- ============================================================================ -- Module Functions -- ============================================================================ function PostScan.OnInitialize() BagTracking.RegisterCallback(private.UpdateOperationDB) Event.Register("AUCTION_HOUSE_SHOW", private.AuctionHouseShowHandler) Event.Register("AUCTION_HOUSE_CLOSED", private.AuctionHouseClosedHandler) private.operationDB = Database.NewSchema("AUCTIONING_OPERATIONS") :AddUniqueStringField("autoBaseItemString") :AddStringField("firstOperation") :Commit() private.scanThreadId = Threading.New("POST_SCAN", private.ScanThread) private.queueDB = Database.NewSchema("AUCTIONING_POST_QUEUE") :AddNumberField("auctionId") :AddStringField("itemString") :AddStringField("operationName") :AddNumberField("bid") :AddNumberField("buyout") :AddNumberField("itemBuyout") :AddNumberField("stackSize") :AddNumberField("numStacks") :AddNumberField("postTime") :AddNumberField("numProcessed") :AddNumberField("numConfirmed") :AddNumberField("numFailed") :AddIndex("auctionId") :AddIndex("itemString") :Commit() -- We maintain our own bag database rather than using the one in BagTracking since we need to be able to remove items -- as they are posted, without waiting for bag update events, and control when our DB updates. private.bagDB = Database.NewSchema("AUCTIONING_POST_BAGS") :AddStringField("itemString") :AddNumberField("bag") :AddNumberField("slot") :AddNumberField("quantity") :AddUniqueNumberField("slotId") :AddIndex("itemString") :AddIndex("slotId") :Commit() -- create a groups and operations query just to register for updates private.groupsQuery = TSM.Groups.CreateQuery() :SetUpdateCallback(private.OnGroupsOperationsChanged) private.operationsQuery = TSM.Operations.CreateQuery() :SetUpdateCallback(private.OnGroupsOperationsChanged) end function PostScan.CreateBagsQuery() return BagTracking.CreateQueryBagsAuctionable() :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") :Distinct("autoBaseItemString") :LeftJoin(private.operationDB, "autoBaseItemString") :InnerJoin(ItemInfo.GetDBForJoin(), "itemString") :OrderBy("name", true) end function PostScan.Prepare() return private.scanThreadId end function PostScan.GetCurrentRow() return private.queueDB:NewQuery() :Custom(private.NextProcessRowQueryHelper) :OrderBy("auctionId", true) :GetFirstResultAndRelease() end function PostScan.GetStatus() return TSM.Auctioning.Util.GetQueueStatus(private.queueDB:NewQuery()) end function PostScan.DoProcess() local result, noRetry = nil, false local postRow = PostScan.GetCurrentRow() local itemString, stackSize, bid, buyout, itemBuyout, postTime = postRow:GetFields("itemString", "stackSize", "bid", "buyout", "itemBuyout", "postTime") local bag, slot = private.GetPostBagSlot(itemString, stackSize) if bag then local _, bagQuantity = GetContainerItemInfo(bag, slot) Log.Info("Posting %s x %d from %d,%d (%d)", itemString, stackSize, bag, slot, bagQuantity or -1) if TSM.IsWowClassic() then -- need to set the duration in the default UI to avoid Blizzard errors AuctionFrameAuctions.duration = postTime ClearCursor() PickupContainerItem(bag, slot) ClickAuctionSellItemButton(AuctionsItemButton, "LeftButton") PostAuction(bid, buyout, postTime, stackSize, 1) ClearCursor() result = true else bid = Math.Round(bid / stackSize, COPPER_PER_SILVER) buyout = Math.Round(buyout / stackSize, COPPER_PER_SILVER) itemBuyout = Math.Round(itemBuyout, COPPER_PER_SILVER) private.itemLocation:Clear() private.itemLocation:SetBagAndSlot(bag, slot) local commodityStatus = C_AuctionHouse.GetItemCommodityStatus(private.itemLocation) if commodityStatus == Enum.ItemCommodityStatus.Item then result = AuctionHouseWrapper.PostItem(private.itemLocation, postTime, stackSize, bid < buyout and bid or nil, buyout) elseif commodityStatus == Enum.ItemCommodityStatus.Commodity then result = AuctionHouseWrapper.PostCommodity(private.itemLocation, postTime, stackSize, itemBuyout) else error("Unknown commodity status: "..tostring(itemString)) end if not result then Log.Err("Failed to post (%s, %s, %s)", itemString, bag, slot) end end else -- we couldn't find this item, so mark this post as failed and we'll try again later result = false noRetry = slot if noRetry then Log.PrintfUser(L["Failed to post %sx%d as the item no longer exists in your bags."], ItemInfo.GetLink(itemString), stackSize) end end if result then private.DebugLogInsert(itemString, "Posting %d from %d, %d", stackSize, bag, slot) if postRow:GetField("numProcessed") + 1 == postRow:GetField("numStacks") then -- update the log local auctionId = postRow:GetField("auctionId") TSM.Auctioning.Log.UpdateRowByIndex(auctionId, "state", "POSTED") end end postRow:SetField("numProcessed", postRow:GetField("numProcessed") + 1) :Update() postRow:Release() return result, noRetry end function PostScan.DoSkip() local postRow = PostScan.GetCurrentRow() local auctionId = postRow:GetField("auctionId") local numStacks = postRow:GetField("numStacks") postRow:SetField("numProcessed", numStacks) :SetField("numConfirmed", numStacks) :Update() postRow:Release() -- update the log TSM.Auctioning.Log.UpdateRowByIndex(auctionId, "state", "SKIPPED") end function PostScan.HandleConfirm(success, canRetry) if not success then ClearCursor() end local confirmRow = private.queueDB:NewQuery() :Custom(private.ConfirmRowQueryHelper) :OrderBy("auctionId", true) :GetFirstResultAndRelease() if not confirmRow then -- we may have posted something outside of TSM return end private.DebugLogInsert(confirmRow:GetField("itemString"), "HandleConfirm(success=%s) x %d", tostring(success), confirmRow:GetField("stackSize")) if canRetry then assert(not success) confirmRow:SetField("numFailed", confirmRow:GetField("numFailed") + 1) end confirmRow:SetField("numConfirmed", confirmRow:GetField("numConfirmed") + 1) :Update() confirmRow:Release() end function PostScan.PrepareFailedPosts() private.queueDB:SetQueryUpdatesPaused(true) local query = private.queueDB:NewQuery() :GreaterThan("numFailed", 0) :OrderBy("auctionId", true) for _, row in query:Iterator() do local numFailed, numProcessed, numConfirmed = row:GetFields("numFailed", "numProcessed", "numConfirmed") assert(numProcessed >= numFailed and numConfirmed >= numFailed) private.DebugLogInsert(row:GetField("itemString"), "Preparing failed (%d, %d, %d)", numFailed, numProcessed, numConfirmed) row:SetField("numFailed", 0) :SetField("numProcessed", numProcessed - numFailed) :SetField("numConfirmed", numConfirmed - numFailed) :Update() end query:Release() private.queueDB:SetQueryUpdatesPaused(false) private.UpdateBagDB() end function PostScan.Reset() private.queueDB:Truncate() private.nextQueueIndex = 1 private.bagDB:Truncate() end function PostScan.ChangePostDetail(field, value) local postRow = PostScan.GetCurrentRow() local isCommodity = ItemInfo.IsCommodity(postRow:GetField("itemString")) if field == "bid" then assert(not isCommodity) value = min(max(value, 1), postRow:GetField("buyout")) elseif field == "buyout" then if not isCommodity and value < postRow:GetField("bid") then postRow:SetField("bid", value) end TSM.Auctioning.Log.UpdateRowByIndex(postRow:GetField("auctionId"), field, value) end postRow:SetField((field == "buyout" and isCommodity) and "itemBuyout" or field, value) :Update() postRow:Release() end -- ============================================================================ -- Private Helper Functions (General) -- ============================================================================ function private.AuctionHouseShowHandler() private.isAHOpen = true private.UpdateOperationDB() end function private.AuctionHouseClosedHandler() private.isAHOpen = false end function private.OnGroupsOperationsChanged() Delay.AfterFrame("POST_GROUP_OPERATIONS_CHANGED", 1, private.UpdateOperationDB) end function private.UpdateOperationDB() if not private.isAHOpen then return end private.operationDB:TruncateAndBulkInsertStart() local query = BagTracking.CreateQueryBagsAuctionable() :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") :Select("autoBaseItemString") :Distinct("autoBaseItemString") for _, itemString in query:Iterator() do local firstOperation = TSM.Operations.GetFirstOperationByItem("Auctioning", itemString) if firstOperation then private.operationDB:BulkInsertNewRow(itemString, firstOperation) end end query:Release() private.operationDB:BulkInsertEnd() end -- ============================================================================ -- Scan Thread -- ============================================================================ function private.ScanThread(auctionScan, scanContext) wipe(private.debugLog) auctionScan:SetScript("OnQueryDone", private.AuctionScanOnQueryDone) private.UpdateBagDB() -- get the state of the player's bags local bagCounts = TempTable.Acquire() local bagQuery = private.bagDB:NewQuery() :Select("itemString", "quantity") for _, itemString, quantity in bagQuery:Iterator() do bagCounts[itemString] = (bagCounts[itemString] or 0) + quantity end bagQuery:Release() -- generate the list of items we want to scan for wipe(private.itemList) for itemString, numHave in pairs(bagCounts) do private.DebugLogInsert(itemString, "Scan thread has %d", numHave) local groupPath = TSM.Groups.GetPathByItem(itemString) local contextFilter = scanContext.isItems and itemString or groupPath if groupPath and tContains(scanContext, contextFilter) and private.CanPostItem(itemString, groupPath, numHave) then tinsert(private.itemList, itemString) end end TempTable.Release(bagCounts) if #private.itemList == 0 then return end -- record this search TSM.Auctioning.SavedSearches.RecordSearch(scanContext, scanContext.isItems and "postItems" or "postGroups") -- run the scan auctionScan:AddItemListQueriesThreaded(private.itemList) for _, query in auctionScan:QueryIterator() do query:SetIsBrowseDoneFunction(private.QueryIsBrowseDoneFunction) query:AddCustomFilter(private.QueryBuyoutFilter) end if not auctionScan:ScanQueriesThreaded() then Log.PrintUser(L["TSM failed to scan some auctions. Please rerun the scan."]) end end -- ============================================================================ -- Private Helper Functions for Scanning -- ============================================================================ function private.UpdateBagDB() private.bagDB:TruncateAndBulkInsertStart() local query = BagTracking.CreateQueryBagsAuctionable() :OrderBy("slotId", true) :VirtualField("autoBaseItemString", "string", TSM.Groups.TranslateItemString, "itemString") :Select("slotId", "bag", "slot", "autoBaseItemString", "quantity") for _, slotId, bag, slot, itemString, quantity in query:Iterator() do private.DebugLogInsert(itemString, "Updating bag DB with %d in %d, %d", quantity, bag, slot) private.bagDB:BulkInsertNewRow(itemString, bag, slot, quantity, slotId) end query:Release() private.bagDB:BulkInsertEnd() end function private.CanPostItem(itemString, groupPath, numHave) local hasValidOperation, hasInvalidOperation = false, false for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do local isValid, numUsed = private.IsOperationValid(itemString, numHave, operationName, operationSettings) if isValid == true then assert(numUsed and numUsed > 0) numHave = numHave - numUsed hasValidOperation = true elseif isValid == false then hasInvalidOperation = true else -- we are ignoring this operation assert(isValid == nil, "Invalid return value") end end return hasValidOperation and not hasInvalidOperation end function private.IsOperationValid(itemString, num, operationName, operationSettings) local postCap = TSM.Auctioning.Util.GetPrice("postCap", operationSettings, itemString) if not postCap then -- invalid postCap setting if not TSM.db.global.auctioningOptions.disableInvalidMsg then Log.PrintfUser(L["Did not post %s because your post cap (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.postCap) end TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) return nil elseif postCap == 0 then -- posting is disabled, so ignore this operation TSM.Auctioning.Log.AddEntry(itemString, operationName, "postDisabled", "", 0, math.huge) return nil end local stackSize = nil local minPostQuantity = nil if not TSM.IsWowClassic() then minPostQuantity = 1 else -- check the stack size stackSize = TSM.Auctioning.Util.GetPrice("stackSize", operationSettings, itemString) if not stackSize then -- invalid stackSize setting if not TSM.db.global.auctioningOptions.disableInvalidMsg then Log.PrintfUser(L["Did not post %s because your stack size (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.stackSize) end TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) return nil end local maxStackSize = ItemInfo.GetMaxStack(itemString) minPostQuantity = operationSettings.stackSizeIsCap and 1 or stackSize if not maxStackSize then -- couldn't lookup item info for this item (shouldn't happen) if not TSM.db.global.auctioningOptions.disableInvalidMsg then Log.PrintfUser(L["Did not post %s because Blizzard didn't provide all necessary information for it. Try again later."], ItemInfo.GetLink(itemString)) end TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) return false elseif maxStackSize < minPostQuantity then -- invalid stack size return nil end end -- check that we have enough to post local keepQuantity = TSM.Auctioning.Util.GetPrice("keepQuantity", operationSettings, itemString) if not keepQuantity then -- invalid keepQuantity setting if not TSM.db.global.auctioningOptions.disableInvalidMsg then Log.PrintfUser(L["Did not post %s because your keep quantity (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.keepQuantity) end TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) return nil end num = num - keepQuantity if num < minPostQuantity then -- not enough items to post for this operation TSM.Auctioning.Log.AddEntry(itemString, operationName, "postNotEnough", "", 0, math.huge) return nil end -- check the max expires local maxExpires = TSM.Auctioning.Util.GetPrice("maxExpires", operationSettings, itemString) if not maxExpires then -- invalid maxExpires setting if not TSM.db.global.auctioningOptions.disableInvalidMsg then Log.PrintfUser(L["Did not post %s because your max expires (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxExpires) end TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) return nil end if maxExpires > 0 then local numExpires = TSM.Accounting.Auctions.GetNumExpiresSinceSale(itemString) if numExpires and numExpires > maxExpires then -- too many expires, so ignore this operation TSM.Auctioning.Log.AddEntry(itemString, operationName, "postMaxExpires", "", 0, math.huge) return nil end end local errMsg = nil local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString) local normalPrice = TSM.Auctioning.Util.GetPrice("normalPrice", operationSettings, itemString) local maxPrice = TSM.Auctioning.Util.GetPrice("maxPrice", operationSettings, itemString) local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString) if not minPrice then errMsg = format(L["Did not post %s because your minimum price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.minPrice) elseif not maxPrice then errMsg = format(L["Did not post %s because your maximum price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxPrice) elseif not normalPrice then errMsg = format(L["Did not post %s because your normal price (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.normalPrice) elseif not undercut then errMsg = format(L["Did not post %s because your undercut (%s) is invalid. Check your settings."], ItemInfo.GetLink(itemString), operationSettings.undercut) elseif normalPrice < minPrice then errMsg = format(L["Did not post %s because your normal price (%s) is lower than your minimum price (%s). Check your settings."], ItemInfo.GetLink(itemString), operationSettings.normalPrice, operationSettings.minPrice) elseif maxPrice < minPrice then errMsg = format(L["Did not post %s because your maximum price (%s) is lower than your minimum price (%s). Check your settings."], ItemInfo.GetLink(itemString), operationSettings.maxPrice, operationSettings.minPrice) end if errMsg then if not TSM.db.global.auctioningOptions.disableInvalidMsg then Log.PrintUser(errMsg) end TSM.Auctioning.Log.AddEntry(itemString, operationName, "invalidItemGroup", "", 0, math.huge) return false else local vendorSellPrice = ItemInfo.GetVendorSell(itemString) or 0 if vendorSellPrice > 0 and minPrice <= vendorSellPrice / 0.95 then -- just a warning, not an error Log.PrintfUser(L["WARNING: Your minimum price for %s is below its vendorsell price (with AH cut taken into account). Consider raising your minimum price, or vendoring the item."], ItemInfo.GetLink(itemString)) end return true, (TSM.IsWowClassic() and stackSize or 1) * postCap end end function private.QueryBuyoutFilter(_, row) local _, itemBuyout, minItemBuyout = row:GetBuyouts() return (itemBuyout and itemBuyout == 0) or (minItemBuyout and minItemBuyout == 0) end function private.QueryIsBrowseDoneFunction(query) if not TSM.IsWowClassic() then return false end local isDone = true for itemString in query:ItemIterator() do isDone = isDone and private.QueryIsBrowseDoneForItem(query, itemString) end return isDone end function private.QueryIsBrowseDoneForItem(query, itemString) local groupPath = TSM.Groups.GetPathByItem(itemString) if not groupPath then return true end local isFilterDone = true for _, _, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do if isFilterDone then local numBuyouts, minItemBuyout, maxItemBuyout = 0, nil, nil for _, subRow in query:ItemSubRowIterator(itemString) do local _, itemBuyout = subRow:GetBuyouts() local timeLeft = subRow:GetListingInfo() if itemBuyout > 0 and timeLeft > operationSettings.ignoreLowDuration then numBuyouts = numBuyouts + 1 minItemBuyout = min(minItemBuyout or math.huge, itemBuyout) maxItemBuyout = max(maxItemBuyout or 0, itemBuyout) end end if numBuyouts <= 1 then -- there is only one distinct item buyout, so can't stop yet isFilterDone = false else local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString) local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString) if not minPrice or not undercut then -- the min price or undercut is not valid, so just keep scanning isFilterDone = false elseif minItemBuyout - undercut <= minPrice then local resetPrice = TSM.Auctioning.Util.GetPrice("priceReset", operationSettings, itemString) if operationSettings.priceReset == "ignore" or (resetPrice and maxItemBuyout <= resetPrice) then -- we need to keep scanning to handle the reset price (always keep scanning for "ignore") isFilterDone = false end end end end end return isFilterDone end function private.AuctionScanOnQueryDone(_, query) for itemString in query:ItemIterator() do local groupPath = TSM.Groups.GetPathByItem(itemString) if groupPath then local numHave = 0 local bagQuery = private.bagDB:NewQuery() :Select("quantity", "bag", "slot") :Equal("itemString", itemString) for _, quantity, bag, slot in bagQuery:Iterator() do numHave = numHave + quantity private.DebugLogInsert(itemString, "Filter done and have %d in %d, %d", numHave, bag, slot) end bagQuery:Release() for _, operationName, operationSettings in TSM.Operations.GroupOperationIterator("Auctioning", groupPath) do if private.IsOperationValid(itemString, numHave, operationName, operationSettings) then local keepQuantity = TSM.Auctioning.Util.GetPrice("keepQuantity", operationSettings, itemString) assert(keepQuantity) local operationNumHave = numHave - keepQuantity if operationNumHave > 0 then assert(not next(private.subRowsTemp)) TSM.Auctioning.Util.GetFilteredSubRows(query, itemString, operationSettings, private.subRowsTemp) local reason, numUsed, itemBuyout, seller, auctionId = private.GeneratePosts(itemString, operationName, operationSettings, operationNumHave, private.subRowsTemp) wipe(private.subRowsTemp) numHave = numHave - (numUsed or 0) seller = seller or "" auctionId = auctionId or math.huge TSM.Auctioning.Log.AddEntry(itemString, operationName, reason, seller, itemBuyout or 0, auctionId) end end end assert(numHave >= 0) else Log.Warn("Item removed from group since start of scan: %s", itemString) end end end function private.GeneratePosts(itemString, operationName, operationSettings, numHave, subRows) if numHave == 0 then return "postNotEnough" end local perAuction, maxCanPost = nil, nil local postCap = TSM.Auctioning.Util.GetPrice("postCap", operationSettings, itemString) if not TSM.IsWowClassic() then perAuction = min(postCap, numHave) maxCanPost = 1 else local stackSize = TSM.Auctioning.Util.GetPrice("stackSize", operationSettings, itemString) local maxStackSize = ItemInfo.GetMaxStack(itemString) if stackSize > maxStackSize and not operationSettings.stackSizeIsCap then return "postNotEnough" end perAuction = min(stackSize, maxStackSize) maxCanPost = min(floor(numHave / perAuction), postCap) if maxCanPost == 0 then if operationSettings.stackSizeIsCap then perAuction = numHave maxCanPost = 1 else -- not enough for single post return "postNotEnough" end end end local lowestAuction = TempTable.Acquire() if not TSM.Auctioning.Util.GetLowestAuction(subRows, itemString, operationSettings, lowestAuction) then TempTable.Release(lowestAuction) lowestAuction = nil end local minPrice = TSM.Auctioning.Util.GetPrice("minPrice", operationSettings, itemString) local normalPrice = TSM.Auctioning.Util.GetPrice("normalPrice", operationSettings, itemString) local maxPrice = TSM.Auctioning.Util.GetPrice("maxPrice", operationSettings, itemString) local undercut = TSM.Auctioning.Util.GetPrice("undercut", operationSettings, itemString) local resetPrice = TSM.Auctioning.Util.GetPrice("priceReset", operationSettings, itemString) local aboveMax = TSM.Auctioning.Util.GetPrice("aboveMax", operationSettings, itemString) local reason, bid, buyout, seller, activeAuctions = nil, nil, nil, nil, 0 if not lowestAuction then -- post as many as we can at the normal price reason = "postNormal" buyout = normalPrice elseif lowestAuction.hasInvalidSeller then -- we didn't get all the necessary seller info Log.PrintfUser(L["The seller name of the lowest auction for %s was not given by the server. Skipping this item."], ItemInfo.GetLink(itemString)) TempTable.Release(lowestAuction) return "invalidSeller" elseif lowestAuction.isBlacklist and lowestAuction.isPlayer then Log.PrintfUser(L["Did not post %s because you or one of your alts (%s) is on the blacklist which is not allowed. Remove this character from your blacklist."], ItemInfo.GetLink(itemString), lowestAuction.seller) TempTable.Release(lowestAuction) return "invalidItemGroup" elseif lowestAuction.isBlacklist and lowestAuction.isWhitelist then Log.PrintfUser(L["Did not post %s because the owner of the lowest auction (%s) is on both the blacklist and whitelist which is not allowed. Adjust your settings to correct this issue."], ItemInfo.GetLink(itemString), lowestAuction.seller) TempTable.Release(lowestAuction) return "invalidItemGroup" elseif lowestAuction.buyout - undercut < minPrice then seller = lowestAuction.seller if resetPrice then -- lowest is below the min price, but there is a reset price assert(RESET_REASON_LOOKUP[operationSettings.priceReset], "Unexpected 'below minimum price' setting: "..tostring(operationSettings.priceReset)) reason = RESET_REASON_LOOKUP[operationSettings.priceReset] buyout = resetPrice bid = max(bid or buyout * operationSettings.bidPercent, minPrice) activeAuctions = TSM.Auctioning.Util.GetPlayerAuctionCount(subRows, itemString, operationSettings, floor(bid), buyout, perAuction) elseif lowestAuction.isBlacklist then -- undercut the blacklisted player reason = "postBlacklist" buyout = lowestAuction.buyout - undercut else -- don't post this item TempTable.Release(lowestAuction) return "postBelowMin", nil, nil, seller end elseif lowestAuction.isPlayer or (lowestAuction.isWhitelist and TSM.db.global.auctioningOptions.matchWhitelist) then -- we (or a whitelisted play we should match) are lowest, so match the current price and post as many as we can activeAuctions = TSM.Auctioning.Util.GetPlayerAuctionCount(subRows, itemString, operationSettings, lowestAuction.bid, lowestAuction.buyout, perAuction) if lowestAuction.isPlayer then reason = "postPlayer" else reason = "postWhitelist" end bid = lowestAuction.bid buyout = lowestAuction.buyout seller = lowestAuction.seller elseif lowestAuction.isWhitelist then -- don't undercut a whitelisted player seller = lowestAuction.seller TempTable.Release(lowestAuction) return "postWhitelistNoPost", nil, nil, seller elseif (lowestAuction.buyout - undercut) > maxPrice then -- we'd be posting above the max price, so resort to the aboveMax setting seller = lowestAuction.seller if operationSettings.aboveMax == "none" then TempTable.Release(lowestAuction) return "postAboveMaxNoPost", nil, nil, seller end assert(ABOVE_MAX_REASON_LOOKUP[operationSettings.aboveMax], "Unexpected 'above max price' setting: "..tostring(operationSettings.aboveMax)) reason = ABOVE_MAX_REASON_LOOKUP[operationSettings.aboveMax] buyout = aboveMax else -- we just need to do a normal undercut of the lowest auction reason = "postUndercut" buyout = lowestAuction.buyout - undercut seller = lowestAuction.seller end if reason == "postBlacklist" then bid = bid or buyout * operationSettings.bidPercent else buyout = max(buyout, minPrice) bid = max(bid or buyout * operationSettings.bidPercent, minPrice) end if lowestAuction then TempTable.Release(lowestAuction) end if TSM.IsWowClassic() then bid = floor(bid) else bid = max(Math.Round(bid, COPPER_PER_SILVER), COPPER_PER_SILVER) buyout = max(Math.Round(buyout, COPPER_PER_SILVER), COPPER_PER_SILVER) end bid = min(bid, TSM.IsWowClassic() and MAXIMUM_BID_PRICE or MAXIMUM_BID_PRICE - 99) buyout = min(buyout, TSM.IsWowClassic() and MAXIMUM_BID_PRICE or MAXIMUM_BID_PRICE - 99) -- check if we can't post anymore local queueQuery = private.queueDB:NewQuery() :Select("numStacks") :Equal("itemString", itemString) :Equal("stackSize", perAuction) :Equal("itemBuyout", buyout) for _, numStacks in queueQuery:Iterator() do activeAuctions = activeAuctions + numStacks end queueQuery:Release() if TSM.IsWowClassic() then maxCanPost = min(postCap - activeAuctions, maxCanPost) else perAuction = min(postCap - activeAuctions, perAuction) end if maxCanPost <= 0 or perAuction <= 0 then return "postTooMany" end if TSM.IsWowClassic() and (bid * perAuction > MAXIMUM_BID_PRICE or buyout * perAuction > MAXIMUM_BID_PRICE) then Log.PrintfUser(L["The buyout price for %s would be above the maximum allowed price. Skipping this item."], ItemInfo.GetLink(itemString)) return "invalidItemGroup" end -- insert the posts into our DB local auctionId = private.nextQueueIndex local postTime = operationSettings.duration local extraStack = 0 if TSM.IsWowClassic() then private.AddToQueue(itemString, operationName, bid, buyout, perAuction, maxCanPost, postTime) -- check if we can post an extra partial stack extraStack = (maxCanPost < postCap and operationSettings.stackSizeIsCap and (numHave % perAuction)) or 0 else assert(maxCanPost == 1) if ItemInfo.IsCommodity(itemString) then local maxPerAuction = ItemInfo.GetMaxStack(itemString) * MAX_COMMODITY_STACKS_PER_AUCTION maxCanPost = floor(perAuction / maxPerAuction) -- check if we can post an extra partial stack extraStack = perAuction % maxPerAuction perAuction = min(perAuction, maxPerAuction) else -- post non-commodities as single stacks maxCanPost = perAuction perAuction = 1 end assert(maxCanPost > 0 or extraStack > 0) if maxCanPost > 0 then private.AddToQueue(itemString, operationName, bid, buyout, perAuction, maxCanPost, postTime) end end if extraStack > 0 then private.AddToQueue(itemString, operationName, bid, buyout, extraStack, 1, postTime) end return reason, (perAuction * maxCanPost) + extraStack, buyout, seller, auctionId end function private.AddToQueue(itemString, operationName, itemBid, itemBuyout, stackSize, numStacks, postTime) private.DebugLogInsert(itemString, "Queued %d stacks of %d", stackSize, numStacks) private.queueDB:NewRow() :SetField("auctionId", private.nextQueueIndex) :SetField("itemString", itemString) :SetField("operationName", operationName) :SetField("bid", itemBid * stackSize) :SetField("buyout", itemBuyout * stackSize) :SetField("itemBuyout", itemBuyout) :SetField("stackSize", stackSize) :SetField("numStacks", numStacks) :SetField("postTime", postTime) :SetField("numProcessed", 0) :SetField("numConfirmed", 0) :SetField("numFailed", 0) :Create() private.nextQueueIndex = private.nextQueueIndex + 1 end -- ============================================================================ -- Private Helper Functions for Posting -- ============================================================================ function private.GetPostBagSlot(itemString, quantity) -- start with the slot which is closest to the desired stack size local bag, slot = private.bagDB:NewQuery() :Select("bag", "slot") :Equal("itemString", itemString) :GreaterThanOrEqual("quantity", quantity) :OrderBy("quantity", true) :GetFirstResultAndRelease() if not bag then bag, slot = private.bagDB:NewQuery() :Select("bag", "slot") :Equal("itemString", itemString) :LessThanOrEqual("quantity", quantity) :OrderBy("quantity", false) :GetFirstResultAndRelease() end if not bag or not slot then -- this item was likely removed from the player's bags, so just give up Log.Err("Failed to find initial bag / slot (%s, %d)", itemString, quantity) return nil, true end local removeContext = TempTable.Acquire() bag, slot = private.ItemBagSlotHelper(itemString, bag, slot, quantity, removeContext) local bagItemString = ItemString.Get(GetContainerItemLink(bag, slot)) if not bagItemString or TSM.Groups.TranslateItemString(bagItemString) ~= itemString then -- something changed with the player's bags so we can't post the item right now TempTable.Release(removeContext) private.DebugLogInsert(itemString, "Bags changed") return nil, nil end local _, _, _, quality = GetContainerItemInfo(bag, slot) assert(quality) if quality == -1 then -- the game client doesn't have item info cached for this item, so we can't post it yet TempTable.Release(removeContext) private.DebugLogInsert(itemString, "No item info") return nil, nil end for slotId, removeQuantity in pairs(removeContext) do private.RemoveBagQuantity(slotId, removeQuantity) end TempTable.Release(removeContext) private.DebugLogInsert(itemString, "GetPostBagSlot(%d) -> %d, %d", quantity, bag, slot) return bag, slot end function private.ItemBagSlotHelper(itemString, bag, slot, quantity, removeContext) local slotId = SlotId.Join(bag, slot) -- try to post completely from the selected slot local found = private.bagDB:NewQuery() :Select("slotId") :Equal("slotId", slotId) :GreaterThanOrEqual("quantity", quantity) :GetFirstResultAndRelease() if found then removeContext[slotId] = quantity return bag, slot end -- try to find a stack at a lower slot which has enough to post from local foundSlotId, foundBag, foundSlot = private.bagDB:NewQuery() :Select("slotId", "bag", "slot") :Equal("itemString", itemString) :LessThan("slotId", slotId) :GreaterThanOrEqual("quantity", quantity) :OrderBy("slotId", true) :GetFirstResultAndRelease() if foundSlotId then removeContext[foundSlotId] = quantity return foundBag, foundSlot end -- try to post using the selected slot and the lower slots local selectedQuantity = private.bagDB:NewQuery() :Select("quantity") :Equal("slotId", slotId) :GetFirstResultAndRelease() local query = private.bagDB:NewQuery() :Select("slotId", "quantity") :Equal("itemString", itemString) :LessThan("slotId", slotId) :OrderBy("slotId", true) local numNeeded = quantity - selectedQuantity local numUsed = 0 local usedSlotIds = TempTable.Acquire() for _, rowSlotId, rowQuantity in query:Iterator() do if numNeeded ~= numUsed then numUsed = min(numUsed + rowQuantity, numNeeded) tinsert(usedSlotIds, rowSlotId) end end query:Release() if numNeeded == numUsed then removeContext[slotId] = selectedQuantity for _, rowSlotId in TempTable.Iterator(usedSlotIds) do local rowQuantity = private.bagDB:GetUniqueRowField("slotId", rowSlotId, "quantity") local rowNumUsed = min(numUsed, rowQuantity) numUsed = numUsed - rowNumUsed removeContext[rowSlotId] = (removeContext[rowSlotId] or 0) + rowNumUsed end return bag, slot else TempTable.Release(usedSlotIds) end -- try posting from the next highest slot local rowBag, rowSlot = private.bagDB:NewQuery() :Select("bag", "slot") :Equal("itemString", itemString) :GreaterThan("slotId", slotId) :OrderBy("slotId", true) :GetFirstResultAndRelease() if not rowBag or not rowSlot then private.ErrorForItem(itemString, "Failed to find next highest bag / slot") end return private.ItemBagSlotHelper(itemString, rowBag, rowSlot, quantity, removeContext) end function private.RemoveBagQuantity(slotId, quantity) local row = private.bagDB:GetUniqueRow("slotId", slotId) local remainingQuantity = row:GetField("quantity") - quantity private.DebugLogInsert(row:GetField("itemString"), "Removing %d (%d remain) from %d", quantity, remainingQuantity, slotId) if remainingQuantity > 0 then row:SetField("quantity", remainingQuantity) :Update() else assert(remainingQuantity == 0) private.bagDB:DeleteRow(row) end row:Release() end function private.ConfirmRowQueryHelper(row) return row:GetField("numConfirmed") < row:GetField("numProcessed") end function private.NextProcessRowQueryHelper(row) return row:GetField("numProcessed") < row:GetField("numStacks") end function private.DebugLogInsert(itemString, ...) tinsert(private.debugLog, itemString) tinsert(private.debugLog, format(...)) end function private.ErrorForItem(itemString, errorStr) for i = 1, #private.debugLog, 2 do if private.debugLog[i] == itemString then Log.Info(private.debugLog[i + 1]) end end Log.Info("Bag state:") for b = 0, NUM_BAG_SLOTS do for s = 1, GetContainerNumSlots(b) do if ItemString.GetBase(GetContainerItemLink(b, s)) == itemString then local _, q = GetContainerItemInfo(b, s) Log.Info("%d in %d, %d", q, b, s) end end end error(errorStr, 2) end