Last active
August 1, 2023 20:24
-
-
Save umnikos/72d4d537822b5f347e294edda2497648 to your computer and use it in GitHub Desktop.
computercraft program: move items from A to B without any hassle
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- Copyright umnikos (Alex Stefanov) 2023 | |
-- Licensed under MIT license | |
-- Version 1.2.1 | |
-- CURRENTLY BUGGY, USE v1.2! | |
-- https://gist.githubusercontent.com/umnikos/72d4d537822b5f347e294edda2497648/raw/ad3e070385daab08b56dfbc1c9029290612dbba1/hopper.lua | |
local help_message = [[ | |
hopper script v1.2.1, made by umnikos | |
usage: | |
hopper {from} {to} [{item name}/{flag}]* | |
example: | |
hopper *chest* *barrel* *:pink_wool -negate | |
for a list of all valid flags | |
view the source file]] | |
-- 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) | |
-- -negate: instead of transferring if any filter matches, transfer if no filters match | |
-- -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 | |
-- -transfer_limit [num]: move at most this many items per iteration (useful for ratelimiting) | |
-- -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 | |
-- TODO: take into account NBT data when stacking | |
-- TODO: parallelize inventory calls for super fast operations | |
-- TODO: use inventoryUpdate event to optimize scanning and sleeping | |
-- TODO: figure out reasons why this isn't an accidental reimplementation of abstractInvLib.lua | |
-- TODO: slot ranges | |
-- TODO: support introspection modules as peripherals | |
-- TODO: `/` for multiple hopper operations with the same scan (conveniently also implementing prioritization) | |
-- TODO: iptables-inspired item routing? | |
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) | |
p = string.gsub(p,"*",".*") | |
p = string.gsub(p,"-","%%-") | |
p = "^"..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 | |
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 options.transfer_limit then | |
print("transfering up to "..tostring(options.transfer_limit).." items per iteration") | |
end | |
local not_string = " " | |
if options.negate then not_string = " not " end | |
if #filters == 1 then | |
print("only the items"..not_string.."matching the filter "..filters[1]) | |
elseif #filters > 1 then | |
print("only the items"..not_string.."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 | |
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 | |
self = p.getNameLocal() | |
return | |
end | |
end | |
end | |
local function transfer(from,to,from_slot,to_slot,count) | |
if count <= 0 then | |
return 0 | |
end | |
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) | |
-- this bs doesn't return how many items were moved | |
turtle.transferTo(to_slot,count) | |
-- so we'll just trust that the math we used to get `count` is correct | |
return 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) | |
local total_transferred = 0 | |
-- 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) ~= (options.negate or false) 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 | |
-- FIXME: program hangs if dest contains a partial stack and is over the -to_limit | |
-- TODO: prove that this overcomplicated logic always halts | |
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(transferred) | |
source_lists[source_name][source_i].count = source_lists[source_name][source_i].count - transferred | |
chest_contains[source_name][item_name] = (chest_contains[source_name][item_name] or 0) - transferred | |
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(transferred) | |
chest_contains[dest_name][item_name] = (chest_contains[dest_name][item_name] or 0) + transferred | |
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 + transferred | |
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 | |
if transferred == 0 and (chest_contains[source_name][item_name] or 0) ~= options.from_limit then | |
ddi = ddi - 1 | |
end | |
end | |
end | |
while true do | |
if (options.transfer_limit or math.huge) - total_transferred == 0 then break end | |
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 | |
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), | |
(options.transfer_limit or math.huge) - total_transferred | |
) | |
local transferred = transfer(source_name,dest_name,source_i,dest_i,amount) | |
total_transferred = total_transferred + transferred | |
update_source(transferred) | |
update_dest(transferred) | |
end | |
end | |
return total_transferred | |
end | |
local function hopper(from,to,filters,options) | |
options = default_options(options) | |
filters = default_filters(filters) | |
determine_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] == "-negate" or args[i] == "-negated" then | |
options.negate = true | |
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] == "-transfer_limit" then | |
i = i+1 | |
options.transfer_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() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment