-- Copyright umnikos (Alex Stefanov) 2023 -- Licensed under MIT license -- Version 1.2BETA -- TODO: take into account NBT data when stacking local help_message = [[ hopper script v1.2, made by umnikos usage: hopper {from} {to} [{item name}/{flag}]* example: hopper *chest* *barrel* *:pink_wool flags: -once : run the script only once instead of in a loop (undo with -forever) -quiet: print less things to the terminal (undo with -verbose) -from_slot [slot]: restrict pulling to a single slot -to_slot [slot]: restrict pushing to a single slot -from_limit [num]: keep at least this many matching items in every source chest -to_limit [num]: fill every destination chest with at most this many matching items -sleep [num]: set the delay in seconds between each iteration (default is 1)]] -- further things of note: -- - `self` is a valid peripheral name if you're running the script from a turtle connected to a wired modem -- - you can import this file as a library with `require "hopper"` -- - the script will prioritize taking from almost empty stacks and filling into almost full stacks local function noop() end local print = print -- for debugging purposes local function dump(o) if type(o) == 'table' then local s = '{ ' for k,v in pairs(o) do if type(k) ~= 'number' then k = '"'..k..'"' end s = s .. '['..k..'] = ' .. dump(v) .. ',' end return s .. '} ' else return tostring(o) end end local function is_empty(t) return next(t) == nil end local function glob(p, s) local p = "^"..string.gsub(p,"*",".*").."$" local res = string.find(s,p) return res ~= nil end local function default_options(options) if not options then options = {} end if options.quiet == nil then options.quiet = true end if options.once == nil then options.once = true end if options.sleep == nil then options.sleep = 1 end --IDEA: to/from slot ranges instead of singular slots --if type(options.from_slot) == "number" then -- options.from_slot = {options.from_slot, options.from_slot} --end --if type(options.to_slot) == "number" then -- options.to_slot = {options.to_slot, options.to_slot} --end return options end local function default_filters(filters) if not filters then filters = {} end if type(filters) == "string" then filters = {filters} end return filters end local function display_info(from, to, sources, destinations, filters, options) if options.quiet then print = noop end print("hoppering from "..from) if options.from_slot then print("and only from slot "..tostring(options.from_slot)) end if options.from_limit then print("keeping at least "..tostring(options.from_limit).." items in reserve per container") end if #sources == 0 then print("except there's nothing matching that description!") return false end print("to "..to) if #destinations == 0 then print("except there's nothing matching that description!") return false end if options.to_slot then print("and only to slot "..tostring(options.to_slot)) end if options.to_limit then print("filling up to "..tostring(options.to_limit).." items per container") end if #filters == 1 and filters[1] ~= "*" then print("only the items matching the filter "..filters[1]) elseif #filters > 1 then print("only the items matching any of the "..tostring(#filters).." filters") end return true end local function matches_filters(filters,s) if #filters == 0 then return true end for _,filter in pairs(filters) do --print(filter) if glob(filter,s) then return true end end return false end -- if the computer has storage (aka. is a turtle) -- we'd like to be able to transfer to it local self = nil local function determine_self() if not turtle then return end for _,dir in ipairs({"top","front","bottom","back"}) do local p = peripheral.wrap(dir) --print(p) if p and p.getNameLocal then --print("FOUND SELF") self = p.getNameLocal() return end end end local function transfer(from,to,from_slot,to_slot,count) if count <= 0 then --print("WARNING: transfering 0 or less items?!?") return 0 end --print("TRANSFER! from "..from.." to "..to) if from ~= "self" then if to == "self" then to = self end return peripheral.call(from,"pushItems",to,from_slot,count,to_slot) else if to == "self" then turtle.select(from_slot) turtle.transferTo(to_slot,count) else return peripheral.call(to,"pullItems",self,from_slot,count,to_slot) end end end local limits_cache = {} local function chest_list(chest) if chest ~= "self" then local c = peripheral.wrap(chest) local l = c.list() for i,item in pairs(l) do --print(i) if limits_cache[item.name] == nil then limits_cache[item.name] = c.getItemLimit(i) end l[i].limit = limits_cache[item.name] end return l else local l = {} for i=1,16 do l[i] = turtle.getItemDetail(i,true) if l[i] then --print(i) l[i].limit = l[i].maxCount end end return l end end local function chest_size(chest) if chest == "self" then return 16 end return peripheral.call(chest,"size") end local function hopper_step(from,to,sources,dests,filters,options) --print("hopper step") -- get all of the chests' contents -- which we will be updating internally -- in order to not have to list the chests -- over and over again local source_lists = {} local dest_lists = {} for _,source_name in ipairs(sources) do source_lists[source_name] = chest_list(source_name) end for _,dest_name in ipairs(dests) do dest_lists[dest_name] = chest_list(dest_name) end -- we will be iterating over item types to be moved -- as well as over source and destination chests -- in order to capitalize on knowing when the destinations are full -- and when the sources are empty -- so we can stop hoppering early local item_jobs = {} -- we will also prioritize filling items into slots -- that already have existing partial stacks of those items -- ideally there shouldn't be that many partial stacks -- so this won't be horribly slow local partial_source_slots = {} local partial_dest_slots = {} -- for to/from limits we'll also need to know -- how many items per chest we can move -- of every item type local chest_contains = {} for source_name,source_list in pairs(source_lists) do chest_contains[source_name] = chest_contains[source_name] or {} for i,item in pairs(source_list) do if not (options.from_slot and options.from_slot ~= i) then if matches_filters(filters,item.name) then if not item_jobs[item.name] then item_jobs[item.name] = 0 partial_source_slots[item.name] = {} partial_dest_slots[item.name] = {} end item_jobs[item.name] = item_jobs[item.name] + item.count chest_contains[source_name][item.name] = (chest_contains[source_name][item.name] or 0) + item.count if item.count > 0 and item.count < item.limit then partial_source_slots[item.name] = partial_source_slots[item.name] or {} partial_source_slots[item.name][item.count] = partial_source_slots[item.name][item.count] or {} table.insert(partial_source_slots[item.name][item.count], {source_name,i}) end end end end end for dest_name,dest_list in pairs(dest_lists) do chest_contains[dest_name] = chest_contains[dest_name] or {} for i,item in pairs(dest_list) do if not (options.to_slot and options.to_slot ~= i) then if (item_jobs[item.name] or 0) > 0 then -- item name matches filter if so chest_contains[dest_name][item.name] = (chest_contains[dest_name][item.name] or 0) + item.count if item.count > 0 and item.count < item.limit then partial_dest_slots[item.name] = partial_dest_slots[item.name] or {} partial_dest_slots[item.name][item.count] = partial_dest_slots[item.name][item.count] or {} table.insert(partial_dest_slots[item.name][item.count], {dest_name,i}) end end end end end --print(dump(partial_source_slots)) --print(dump(partial_dest_slots)) -- and now for the actual hoppering for item_name,_ in pairs(item_jobs) do -- we first do it for the partially filled source slots only -- into partially filled destinations only local s = partial_source_slots[item_name] local source_counts = {} for c,_ in pairs(s) do table.insert(source_counts,c) end local d = partial_dest_slots[item_name] local dest_counts = {} for c,_ in pairs(d) do table.insert(dest_counts,c) end table.sort(source_counts) table.sort(dest_counts) local si = 1 -- container index local sii = nil -- slot index local ssi = nil -- whole container index local ssii = nil -- whole container slot local di = #dest_counts -- container index local dii = nil -- slot index local ddi = nil -- whole container index if si > #source_counts then ssi = #sources ssii = chest_size(sources[ssi]) end local source_name, source_i, source_amount local dest_name, dest_i, dest_amount local function get_source() if ssi == nil then if not sii then sii = #s[source_counts[si]] end local source_name, source_i = table.unpack(s[source_counts[si]][sii]) local source_amount = source_lists[source_name][source_i].count return source_name, source_i, source_amount else while ssi > 0 do if options.from_slot then ssii = options.from_slot end local item_found = source_lists[sources[ssi]][ssii] -- TODO: replace ~= with comparison operators if item_found and item_found.count > 0 and item_found.name == item_name and chest_contains[sources[ssi]][item_name] ~= options.from_limit then return sources[ssi], ssii, item_found.count end ssii = ssii - 1 if ssii <= 0 or (options.from_slot and ssii < options.from_slot) then ssi = ssi - 1 if ssi <= 0 then break end ssii = chest_size(sources[ssi]) end end return nil, nil, nil end end local function update_source(amount, transferred) -- planned vs actual transffered amount source_lists[source_name][source_i].count = source_lists[source_name][source_i].count - amount chest_contains[source_name][item_name] = (chest_contains[source_name][item_name] or 0) - amount if ssi == nil then if source_lists[source_name][source_i].count == 0 or chest_contains[source_name][item_name] == options.from_limit then sii = sii - 1 end if sii <= 0 then si = si + 1 sii = nil if si > #source_counts then ssi = #sources ssii = chest_size(sources[ssi]) end end end end local function get_dest() if di and di < 1 then ddi = #dests di = nil dii = nil end if ddi == nil then if not dii then dii = #d[dest_counts[di]] end local dest_name, dest_i = table.unpack(d[dest_counts[di]][dii]) local dest_amount = dest_lists[dest_name][dest_i].limit - dest_lists[dest_name][dest_i].count return dest_name, dest_i, dest_amount else if options.to_slot then -- find chest where slot is empty while true do if ddi < 1 then break end if (chest_contains[dests[ddi]][item_name] or 0) ~= (options.to_limit or math.huge) then if dest_lists[dests[ddi]][options.to_slot] == nil then break end if dest_lists[dests[ddi]][options.to_slot].count == 0 then break end end ddi = ddi - 1 end return dests[ddi], options.to_slot, math.huge else -- just shove into the chest and move to the next one if 0 get moved return dests[ddi], nil, math.huge end end end local function update_dest(amount, transferred) chest_contains[dest_name][item_name] = (chest_contains[dest_name][item_name] or 0) + amount if ddi == nil then -- TODO: this needs to be a thing even if ddi is not nil, else the list becomes invalid and is not reusable dest_lists[dest_name][dest_i].count = dest_lists[dest_name][dest_i].count + amount if dest_lists[dest_name][dest_i].limit - dest_lists[dest_name][dest_i].count == 0 or chest_contains[dest_name][item_name] == options.to_limit then dii = dii - 1 end if dii <= 0 then di = di - 1 dii = nil end else --print(transferred == 0 and (chest_contains[source_name][item_name] or 0) ~= options.from_limit) if transferred == 0 and (chest_contains[source_name][item_name] or 0) ~= options.from_limit then ddi = ddi - 1 end --print(ddi) end end while true do if item_jobs[item_name] <= 0 then break end source_name, source_i, source_amount = get_source() if source_name == nil then break end dest_name, dest_i, dest_amount = get_dest() if dest_name == nil then break end --print(dump(chest_contains)) local amount = math.min(source_amount, dest_amount, (options.to_limit or math.huge) - (chest_contains[dest_name][item_name] or 0), (chest_contains[source_name][item_name] or 0) - (options.from_limit or 0) ) local transferred = transfer(source_name,dest_name,source_i,dest_i,amount) update_source(amount, transferred) update_dest(amount, transferred) end end end local function hopper(from,to,filters,options) options = default_options(options) filters = default_filters(filters) determine_self() --print("SELF IS:") --print(self) local peripherals = peripheral.getNames() if self then table.insert(peripherals,"self") end local sources = {} local destinations = {} for i,per in ipairs(peripherals) do if glob(from,per) then -- prevent the source and the destination ever being the same -- (if a chest matches both, it's only a destination) if (not glob(to,per)) or (options.to_slot and options.from_slot and options.from_slot ~= options.to_slot) then sources[#sources+1] = per end end if glob(to,per) then destinations[#destinations+1] = per end end local valid = display_info(from,to,sources,destinations,filters,options) if not valid then return end while true do hopper_step(from,to,sources,destinations,filters,options) if options.once then break end sleep(options.sleep) end end local args = {...} local function main() if args[1] == "hopper" then return hopper end if #args < 2 then print(help_message) return end local from = args[1] local to = args[2] local options = {} options.once = false options.quiet = false local filters = {} local i=3 while i <= #args do if glob("-*",args[i]) then if args[i] == "-once" then --print("(only once!)") options.once = true elseif args[i] == "-forever" then options.once = false elseif args[i] == "-quiet" then options.quiet = true elseif args[i] == "-verbose" then options.quiet = false elseif args[i] == "-from_slot" then i = i+1 options.from_slot = tonumber(args[i]) elseif args[i] == "-to_slot" then i = i+1 options.to_slot = tonumber(args[i]) elseif args[i] == "-from_limit" then i = i+1 options.from_limit = tonumber(args[i]) elseif args[i] == "-to_limit" then i = i+1 options.to_limit = tonumber(args[i]) elseif args[i] == "-sleep" then i = i+1 options.sleep = tonumber(args[i]) else print("UNKNOWN ARGUMENT: "..args[i]) return end else filters[#filters+1] = args[i] end i = i+1 end hopper(from,to,filters,options) end return main()