view Modules/Mover.lua @ 171:5f405272cdd4

Now using BAG_UPDATE rather than ITEM_LOCK_CHANGED for refilling from the guild bank. When moving a large quantity of items, the bags will be updated slower than the slots will be unlocked.
author Zerotorescue
date Tue, 25 Jan 2011 19:31:09 +0100
parents 3350c8aa3417
children
line wrap: on
line source
local addon = select(2, ...);
local mod = addon:NewModule("Mover", "AceEvent-3.0", "AceTimer-3.0");

local _G = _G;
local select, pairs = _G.select, _G.pairs;
local tinsert, twipe, treverse, tsort, tremove = _G.table.insert, _G.table.wipe, _G.table.reverse, _G.table.sort, _G.table.remove;

local Scanner;
local queuedMoves = {}; -- table storing all queued moves before BeginMove is called
local combinedMoves = {}; -- table storing all combined moves (with source and target) that is to be processed by the actual mover in the order of the index (1 to #)
local movesSource;

local ContainerFunctions = {
	[addon.Locations.Bag] = {
		GetItemId = GetContainerItemID,
		PickupItem = SplitContainerItem,
		IsLocked = function(sourceContainer, sourceSlot)
			return select(3, GetContainerItemInfo(sourceContainer, sourceSlot));
		end,
		Event = "ITEM_LOCK_CHANGED",
	},
	[addon.Locations.Bank] = {
		GetItemId = GetContainerItemID,
		PickupItem = SplitContainerItem,
		IsLocked = function(sourceContainer, sourceSlot)
			return select(3, GetContainerItemInfo(sourceContainer, sourceSlot));
		end,
		Event = "ITEM_LOCK_CHANGED",
	},
	[addon.Locations.Guild] = {
		GetItemId = function(tabId, slotId)
			return addon:GetItemId(GetGuildBankItemLink(tabId, slotId));
		end,
		PickupItem = SplitGuildBankItem,
		IsLocked = function(sourceContainer, sourceSlot)
			return select(3, GetGuildBankItemInfo(sourceContainer, sourceSlot));
		end,
		Event = "BAG_UPDATE",
	},
	[addon.Locations.Mailbox] = {
		GetItemId = function(mailIndex, attachmentId)
			return addon:GetItemId(GetInboxItemLink(mailIndex, attachmentId));
		end,
		PickupItem = TakeInboxItem,
		IsLocked = function() return false; end,
		DoNotDrop = true, -- TakeInboxItem does not support picking up
		Synchronous = true, -- wait after every single move
		Event = "BAG_UPDATE",
	},
	[addon.Locations.Merchant] = {
		GetItemId = function(_, merchantIndex)
			return addon:GetItemId(GetMerchantItemLink(merchantIndex));
		end,
		PickupItem = function(_, merchantIndex, num)
			return BuyMerchantItem(merchantIndex, num);
		end,
		IsLocked = function() return false; end,
		DoNotDrop = true, -- BuyMerchantItem does not support picking up
		Burst = true, -- spam buy items, the source can take it
		Event = "BAG_UPDATE",
	},
};

function mod:AddMove(itemId, amount, numMissing, numAvailable, cost)
	tinsert(queuedMoves, {
		["itemId"] = itemId,
		["num"] = amount, -- can not be unlimited
		["missing"] = numMissing,
		["available"] = numAvailable,
		["cost"] = cost,
	});
end

function mod:HasMoves()
	return (#queuedMoves ~= 0);
end

function mod:GetMoves()
	return queuedMoves;
end

function mod:ResetQueue()
	twipe(queuedMoves);
end

if not treverse then
	treverse = function(orig)
		local temp = {};
		local origLength = #orig;
		for i = 1, origLength do
			temp[(origLength - i + 1)] = orig[i];
		end
		
-- 		-- Update the original table (can't do orig = temp as that would change the reference-link instead of the original table)
-- 		for i, v in pairs(temp) do
-- 			orig[i] = v;
-- 		end
		return temp; -- for speed we choose to do a return instead
	end
end

local function GetEmptySlots()
	local emptySlots = {};
	
	-- Go through all our bags, including the backpack
	for bagId = 0, NUM_BAG_SLOTS do
		-- Go through all our slots (0 = backpack)
		for slotId = 1, GetContainerNumSlots(bagId) do
			local itemId = GetContainerItemID(bagId, slotId); -- we're scanning our local bags here, so no need to get messy with guild bank support
			local bagFamily = select(2, GetContainerNumFreeSlots(bagId));
			
			if not itemId then
				tinsert(emptySlots, {
					["container"] = bagId,
					["slot"] = slotId,
					["family"] = bagFamily,
				});
			end
		end
	end
	
	return emptySlots;
end

local function GetFirstEmptySlot(emptySlots, prefFamily)
	while prefFamily do
		for _, slot in pairs(emptySlots) do
			if slot.family == prefFamily then
				return slot;
			end
		end
		
		-- If this was a special family, no special bag available, now check normal bags
		if prefFamily > 0 then
			prefFamily = 0;
		else
			prefFamily = nil;
		end
	end
end

function mod:BeginMove(location, onFinish)
	addon:Debug("BeginMove");
	
	-- Find the outgoing moves
	-- We need the source container and slot, find all the requires sources and put them in a list which we go through later to find matching targets
	
	-- Get a list of items in the source container
	local sourceContents = Scanner:CacheLocation(location, false);
	
	local outgoingMoves = {};

	addon:Debug("%d moves were queued.", #queuedMoves);
	
	for _, singleMove in pairs(queuedMoves) do
		local sourceItem = sourceContents[singleMove.itemId];
		if not sourceItem then
			addon:Print(("Can't move %s, this doesn't exist in the source."):format(IdToItemLink(singleMove.itemId)), addon.Colors.Red);
		else
			-- We want to move the smallest stacks first to keep stuff pretty (and minimize space usage, splitting a stack takes 2 slots, moving something only 1)
			tsort(sourceItem.locations, function(a, b)
				-- -1 indicates unlimited, this is always more than an actual amount
				if a.count == -1 then
					return false;
				elseif b.count == -1 then
					return true;
				end
				
				return a.count < b.count;
			end);
			
			for _, itemLocation in pairs(sourceItem.locations) do
				-- if this location has more items than we need, only move what we need, otherwise move everything in this stack
				-- -1 items indicates unlimited amount, in that case we must cap at missing items
				local movingNum = (((itemLocation.count == -1 or itemLocation.count > singleMove.num) and singleMove.num) or itemLocation.count);
				
				if itemLocation.count == -1 then
					-- If the source has an unlimited quantity, makes moves based on the max stacksize
					
					local stackSize = select(8, GetItemInfo(singleMove.itemId)); -- 8 = stacksize
					
					if stackSize then
						while movingNum > stackSize do
							-- Move a single stack size while the amount remaining to be moved is above the stack size num
							
							tinsert(outgoingMoves, {
								["itemId"] = singleMove.itemId,
								["num"] = stackSize,
								["container"] = itemLocation.container,
								["slot"] = itemLocation.slot,
							});
							
							movingNum = (movingNum - stackSize);
							singleMove.num = (singleMove.num - stackSize);
						end
					end
				end
				
				tinsert(outgoingMoves, {
					["itemId"] = singleMove.itemId,
					["num"] = movingNum,
					["container"] = itemLocation.container,
					["slot"] = itemLocation.slot,
				});
				
				singleMove.num = (singleMove.num - movingNum);
				
				if singleMove.num == 0 then
					-- If we have prepared everything we wanted, go to the next queued move
					break; -- stop the locations-loop
				end
			end
		end
	end

	addon:Debug("%d outgoing moves are possible.", #outgoingMoves);
	
	-- No longer needed
	twipe(queuedMoves);
	
	
	
	-- Process every single outgoing move and find fitting targets
	
	-- Get a list of items already in the target container
	local targetContents = Scanner:CacheLocation(addon.Locations.Bag, false);
	
	-- Find all empty slots
	local emptySlots = GetEmptySlots();

	addon:Debug("%d empty slots are available.", #emptySlots);
	
	-- Remember where we're moving from
	movesSource = location;
	
	local backup = 0; -- the below loop should never break, but if it does for any reason, this is here to stop it from freezing the game
	
	while #outgoingMoves ~= 0 do
		-- Repeat the below loop until nothing is remaining
		
		for _, outgoingMove in pairs(outgoingMoves) do
			if outgoingMove.itemId then -- itemId  will be set to nil when this outgoing move was processed - sanity check
				local targetItem = targetContents[outgoingMove.itemId];
				
				if not targetItem or ContainerFunctions[location].DoNotDrop then
					-- There is no partial stack which can be filled or this source container doesn't allow manual allocation of items (in which case we always assume it takes a full empty slot)
					
					local family = GetItemFamily(outgoingMove.itemId);
					if family and family ~= 0 and select(9, GetItemInfo(outgoingMove.itemId)) == "INVTYPE_BAG" then
						-- Containers can only fit in general slots but GetItemFamily will return what they can contain themselves
						family = 0;
					end
					local firstAvailableSlot = GetFirstEmptySlot(emptySlots, family);
					
					if not firstAvailableSlot then
						-- No empty slot available - bags are full
						
						addon:Print(("Bags are full. Skipping %s."):format(IdToItemLink(outgoingMove.itemId)), addon.Colors.Orange);
						
						outgoingMove.itemId = nil; -- remove this record from the outgoingMoves-table
						
						-- Not a single item with this item id can be moved, since we only want the bags are full announcement once, we remove all other moves with this item id
						for _, otherMove in pairs(outgoingMoves) do
							if otherMove.itemId and otherMove.itemId == outgoingMove.itemId then
								otherMove.itemId = nil;
							end
						end
					else
						-- Consume empty slot
						
						tinsert(combinedMoves, {
							["itemId"] = outgoingMove.itemId,
							["num"] = outgoingMove.num,
							["sourceContainer"] = outgoingMove.container,
							["sourceSlot"] = outgoingMove.slot,
							["targetContainer"] = firstAvailableSlot.container,
							["targetSlot"] = firstAvailableSlot.slot,
						});
						
						-- We filled an empty slot so the target contents now has one more item,
						-- make a new instance of the ItemMove class so any additional items with this id can be stacked on top of it
						local itemMove = addon.ContainerItem:New();
						itemMove:AddLocation(firstAvailableSlot.container, firstAvailableSlot.slot, outgoingMove.num);
						targetContents[outgoingMove.itemId] = itemMove;
						
						-- Find the slot used and remove it from the empty slots table
						for index, emptySlot in pairs(emptySlots) do
							if emptySlot == firstAvailableSlot then
								tremove(emptySlots, index);
								break;
							end
						end
						
						outgoingMove.num = 0; -- nothing remaining - sanity check
						outgoingMove.itemId = nil; -- remove this record from the outgoingMoves-table
					end
				else
					-- Find the maximum stack size for this item
					local itemStackCount = select(8, GetItemInfo(outgoingMove.itemId));
					
					-- We want to move to the largest stacks first to keep stuff pretty
					tsort(targetItem.locations, function(a, b)
						return a.count > b.count;
					end);
					
					for _, itemLocation in pairs(targetItem.locations) do
						if itemLocation.count < itemStackCount and outgoingMove.num > 0 then
							-- Check if this stack isn't already full (and we still need to move this item)
							
							local remainingSpace = (itemStackCount - itemLocation.count);
							if remainingSpace >= outgoingMove.num then
								-- Enough room to move this entire stack
								-- Deposit this item and then forget this outgoing move as everything in it was processed
								
								tinsert(combinedMoves, {
									["itemId"] = outgoingMove.itemId,
									["num"] = outgoingMove.num,
									["sourceContainer"] = outgoingMove.container,
									["sourceSlot"] = outgoingMove.slot,
									["targetContainer"] = itemLocation.container,
									["targetSlot"] = itemLocation.slot,
								});
								
								itemLocation.count = (itemLocation.count + outgoingMove.num);
								outgoingMove.num = 0; -- nothing remaining
								outgoingMove.itemId = nil; -- remove this record from the outgoingMoves-table
								break; -- stop the locations-loop
							else
								-- Deposit this item but don't remove the outgoing move as there are some items left to move
								
								tinsert(combinedMoves, {
									["itemId"] = outgoingMove.itemId,
									["num"] = outgoingMove.num,
									["sourceContainer"] = outgoingMove.container,
									["sourceSlot"] = outgoingMove.slot,
									["targetContainer"] = itemLocation.container,
									["targetSlot"] = itemLocation.slot,
								});
								
								-- The target will be full when we complete, but the source will still have remaining items left to be moved
								itemLocation.count = itemStackCount;
								outgoingMove.num = (outgoingMove.num - remainingSpace);
							end
						end
					end
					
					if outgoingMove.num > 0 then
						-- We went through all matching items and checked their stack sizes if we could move this there, no room available
						-- So forget about the target item (even though it may just have full locations, these are useless anyway) and the next loop move it onto an empty slot
						targetContents[outgoingMove.itemId] = nil;
					end
				end
			end
		end
		
		-- Loop through the array to find items that should be removed, start with the last element or the loop would break
		local numOutgoingMoves = #outgoingMoves; -- since LUA-tables start at an index of 1, this is actually an existing index (outgoingMoves[#outgoingMoves] would return a value)
		while numOutgoingMoves ~= 0 do
			-- A not equal-comparison should be quicker than a larger/smaller than-comparison
			
			-- Check if the item id is nil, this is set to nil when this outgoing move has been processed
			if not outgoingMoves[numOutgoingMoves].itemId or outgoingMoves[numOutgoingMoves].num == 0 then
				-- Remove this element from the array
				tremove(outgoingMoves, numOutgoingMoves);
			end
			
			-- Proceed with the next element (or previous considering we're going from last to first)
			numOutgoingMoves = (numOutgoingMoves - 1);
		end

		addon:Debug("%d moves remaining.", #outgoingMoves);
		
		backup = (backup + 1);
		if backup > 1000 then
			twipe(outgoingMoves);
			self:Abort("mover crashed", "Error preparing moves, hit an endless loop");
			onFinish();
			return;
		end
	end
	
	-- Reverse table, we need to go through it from last to first because we'll be removing elements, but we don't want the actions to be executed in a different order
	combinedMoves = treverse(combinedMoves);

	addon:Debug("%d moves should be possible.", #combinedMoves);
	
	-- No longer needed
	twipe(emptySlots);
	
	self:ProcessMove();
	
	-- Even though we aren't completely done yet, allow requeueing
	onFinish();
end

local tmrRetry;
function mod:ProcessMove()
	addon:Debug("ProcessMove");
	
	if #combinedMoves == 0 then
		addon:Print("Nothing to move.");
		
		self:Abort();
		
		return;
	elseif movesSource == addon.Locations.Mailbox and MailAddonBusy and MailAddonBusy ~= addon:GetName() then
		addon:Debug("Anoter addon (%s) is busy with the mailbox.", MailAddonBusy);
		
		self:CancelTimer(tmrRetry, true); -- silent
		tmrRetry = self:ScheduleTimer("ProcessMove", .5);
		
		return;
	end
	
	-- Make sure nothing is at the mouse
	ClearCursor();
	
	if movesSource == addon.Locations.Mailbox then
		MailAddonBusy = addon:GetName();
		
		-- Since mailbox indexes change as mail is emptied (emptied mail is automatically deleted, thus number 50 would become 49 when 1 disappears), we must start with the last mail first
		tsort(combinedMoves, function(a, b)
			return a.sourceContainer < b.sourceContainer;
		end);
	end
	
	self:RegisterEvent(ContainerFunctions[movesSource].Event, "SourceUpdated");
	self:RegisterEvent("UI_ERROR_MESSAGE");
	
	-- combinedMoves now has all moves in it (source -> target)
	-- go through list, move everything inside it
	-- add source and target to lists, if either is already in this list, skip the move
	-- repeat every few seconds until we're completely done
	
	local sourceLocationsLocked = {};
	local targetLocationsLocked = {};
	
	local hasMoved;
	
	local combinedMovesOriginalLength = #combinedMoves;
	local numCurrentMove = combinedMovesOriginalLength;
	while numCurrentMove ~= 0 do
		local move = combinedMoves[numCurrentMove];
		
		-- Only check if the source is locked when we're not bursting (some functions allow mass calling simultaneously and don't trigger item locks)
		local isSourceLocked = ((not ContainerFunctions[movesSource].Burst and sourceLocationsLocked[move.sourceContainer] and sourceLocationsLocked[move.sourceContainer][move.sourceSlot]) or ContainerFunctions[movesSource].IsLocked(move.sourceContainer, move.sourceSlot));
		-- Target are always the local bags
		local isTargetLocked = ((targetLocationsLocked[move.targetContainer] and targetLocationsLocked[move.targetContainer][move.targetSlot]) or ContainerFunctions[addon.Locations.Bag].IsLocked(move.targetContainer, move.targetSlot));
		
		if move and not isSourceLocked and not isTargetLocked and (not ContainerFunctions[movesSource].Synchronous or not hasMoved) then
			addon:Print(("Moving %dx%s."):format(move.num, IdToItemLink(move.itemId)));
			
			addon:Debug("Moving %dx%s from (%d,%d) to (%d,%d)", move.num, IdToItemLink(move.itemId), move.sourceContainer, move.sourceSlot, move.targetContainer, move.targetSlot);
			
			-- Check if the source has been changed since out scan
			if ContainerFunctions[movesSource].GetItemId(move.sourceContainer, move.sourceSlot) ~= move.itemId then
				self:Abort("source changed", "Source (" .. move.sourceContainer .. "," .. move.sourceSlot .. ") is not " .. IdToItemLink(move.itemId));
				return;
			end
			
			-- Pickup stack
			ContainerFunctions[movesSource].PickupItem(move.sourceContainer, move.sourceSlot, move.num);
			
			hasMoved = true;
			
			-- Remember we picked this item up and thus it is now locked
			if not sourceLocationsLocked[move.sourceContainer] then
				sourceLocationsLocked[move.sourceContainer] = {};
			end
			sourceLocationsLocked[move.sourceContainer][move.sourceSlot] = true;
			
			if not ContainerFunctions[movesSource].DoNotDrop then
				-- Some sources don't actually pick items up but just move them, this makes the code below unnessary
				
				if movesSource ~= addon.Locations.Bank or CursorHasItem() then -- CursorHasItem only works when moving from the bank
					-- We are moving into our local bags, so the below must check normal bags
					
					-- Check if the target has been changed since out scan
					local targetItemId = ContainerFunctions[addon.Locations.Bag].GetItemId(move.targetContainer, move.targetSlot);
					if targetItemId and targetItemId ~= move.itemId then
						self:Abort("target changed", "Target (" .. move.targetContainer .. "," .. move.targetSlot .. ") is not " .. IdToItemLink(move.itemId) .. " nor empty");
						return;
					end
					
					-- And drop it (this is always a local bag so no need to do any guild-checks)
					PickupContainerItem(move.targetContainer, move.targetSlot);
					
					-- Remember we dropped an item here and thus this is now locked
					if not targetLocationsLocked[move.targetContainer] then
						targetLocationsLocked[move.targetContainer] = {};
					end
					targetLocationsLocked[move.targetContainer][move.targetSlot] = true;
					
					-- This move was processed
					tremove(combinedMoves, numCurrentMove);
				else
					self:Abort("item disappeared from mouse", "Couldn't move " .. IdToItemLink(move.itemId) .. ", CursorHasItem() is false");
					return;
				end
			else
				-- When items are deposit automatically we still need to remember when a move has been processed
				
				-- This move was processed
				tremove(combinedMoves, numCurrentMove);
			end
		end
		
		-- Proceed with the next element (or previous considering we're going from last to first)
		numCurrentMove = (numCurrentMove - 1);
	end
	
	addon:Debug("%d moves processed. %d moves remaining.", (combinedMovesOriginalLength - #combinedMoves), #combinedMoves);
	
	if #combinedMoves == 0 then
		addon:Print("Finished.", addon.Colors.Green);
		
		self:Abort();
		
		return;
	end
end

local tmrProcessNext;
function mod:SourceUpdated()
	self:CancelTimer(tmrProcessNext, true); -- silent
	tmrProcessNext = self:ScheduleTimer("ProcessMove", .5);
end

function IdToItemLink(itemId)
	local itemLink = select(2, GetItemInfo(itemId));
	itemLink = itemLink or "Unknown (" .. itemId .. ")";
	return itemLink;
end

function mod:UI_ERROR_MESSAGE(e, errorMessage)
	if errorMessage == ERR_SPLIT_FAILED then
		self:Abort("splitting failed", "Splitting failed.");
	elseif errorMessage == ERR_GUILD_WITHDRAW_LIMIT then
		self:Abort("at guild withdrawal limit", "At guild withdrawal limit");
	end
end

function mod:Abort(simple, debugMsg)
	-- Announce
	if debugMsg then
		addon:Debug("Aborting:%s", debugMsg);
	end
	if simple then
		addon:Print(("Aborting: %s."):format(simple), addon.Colors.Red);
	end
	
	-- Make sure nothing is at the mouse
	ClearCursor();
	
	-- Stop timer
	self:UnregisterEvent(ContainerFunctions[movesSource].Event);
	self:CancelTimer(tmrProcessNext, true); -- silent
	
	self:UnregisterEvent("UI_ERROR_MESSAGE");
	
	-- Reset vars
	twipe(combinedMoves);
	movesSource = nil;
	if MailAddonBusy == addon:GetName() then
		MailAddonBusy = nil;
	end
end

function mod:OnEnable()
	Scanner = addon:GetModule("Scanner");
end

function mod:OnDisable()
	Scanner = nil;
	
	self:Abort();
end