-- Copyright umnikos (Alex Stefanov) 2023-2024
-- Licensed under MIT license
local version = "v1.4 BETA7"
local til
local help_message = [[
hopper script ]]..version..[[, made by umnikos
example usage:
hopper *chest* *barrel* *:pink_wool -negate
for more info check out the repo:
https://github.com/umnikos/hopper.lua]]
-- v1.4 changelog:
-- fix display_exit when -debug is used
-- added -max_batch and -batch_multiple to complement -min_batch
-- added `/` syntax
-- added -preserve_slots/-preserve_order - transfer only if source and dest slot numbers match
-- added -alias flag for giving names to patterns
-- example usage: -alias output *ender*_1234
-- added -storage flag for creating til-managed chest/barrel arrays
-- example usage: -storage logs *barrel*
local function halt()
while true do
os.pullEvent("free_lunch")
-- nom nom nom
end
end
local function exitOnTerminate(f)
local status, err = pcall(f)
if status then
return
end
if err == "Terminated" then
return
end
return error(err,0)
end
-- 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 aliases = {}
local function glob(ps, s)
ps = "|"..ps.."|"
local i = #aliases
while i >= 1 do
ps = string.gsub(ps, "(|+)"..aliases[i].name.."(|+)", "%1"..aliases[i].pattern.."%2")
i = i - 1
end
i = 0
for p in string.gmatch(ps, "[^|]+") do
i = i + 1
p = string.gsub(p,"*",".*")
p = string.gsub(p,"-","%%-")
p = "^"..p.."$"
local res = string.find(s,p)
if res ~= nil then
return true, i
end
end
return false
end
local function is_valid_name(s)
return not string.find(s, "%W")
end
local lua_tonumber = tonumber
local function tonumber(s)
local success,num = pcall(function()
-- check most common case first, faster than the general case
if string.find(s,"^%d+$") then
return lua_tonumber(s)
-- with just these characters you can't execute arbitrary code
elseif string.find(s,"^[%d%+%-%*/%(%)%.]+$") then
return load("return "..s)()
else
error("not a number")
end
end)
if not success or num == nil then
error("not a number: "..s)
end
return num
end
local cursor_x,cursor_y = 1,1
local function save_cursor(options)
cursor_x,cursor_y = term.getCursorPos()
local sizex,sizey = term.getSize()
local margin
if options.debug then margin=2 else margin=1 end
cursor_y = math.min(cursor_y, sizey-margin)
end
local function go_back()
term.setCursorPos(cursor_x,cursor_y)
end
local options -- global options during hopper step
local function default_options(options)
if not options then
options = {}
end
if options.quiet == nil then
options.quiet = false
end
if options.once == nil then
options.once = false
end
if options.sleep == nil then
options.sleep = 1
end
return options
end
local filters -- global filters during hopper step
local function format_time(time)
if time < 1000*60*60 then -- less than an hour => format as minutes and seconds
local seconds = math.floor(time/1000)
local minutes = math.floor(seconds/60)
seconds = seconds - 60*minutes
return minutes.."m "..seconds.."s"
else -- format as hours and minutes
local minutes = math.floor(time/1000/60)
local hours = math.floor(minutes/60)
minutes = minutes - 60*hours
return hours.."h "..minutes.."m"
end
end
-- `-storage` objects and a set of peripherals they wrap
-- this is filled up at the start of hopper_loop
local storages = {}
-- list of peripherals that are part of a storage, not to be used directly ever
local peripheral_blacklist = {}
local total_transferred = 0
local hoppering_stage = nil
local start_time
local function display_exit(options, args_string)
if options.quiet then
return
end
local elapsed_time = 0
if start_time then
elapsed_time = os.epoch("utc")-start_time
end
local ips = (total_transferred*1000/elapsed_time)
if ips ~= ips then
ips = 0
end
local ips_rounded = math.floor(ips*100)/100
go_back()
if options.debug then
print(" ")
end
print("total uptime: "..format_time(elapsed_time))
print("transferred total: "..total_transferred.." ("..ips_rounded.." i/s) ")
end
local latest_error = nil
local function display_loop(options, args_string)
if options.quiet then
halt()
end
term.clear()
go_back()
print("hopper.lua "..version)
args_string = args_string:gsub(" / ","\n/ ")
print("$ hopper "..args_string)
print("")
save_cursor(options)
start_time = os.epoch("utc")
local time_to_wake = start_time/1000
while true do
local elapsed_time = os.epoch("utc")-start_time
local ips = (total_transferred*1000/elapsed_time)
if ips ~= ips then
ips = 0
end
local ips_rounded = math.floor(ips*100)/100
go_back()
if options.debug then
print((hoppering_stage or "nilstate").." ")
end
print("uptime: "..format_time(elapsed_time).." ")
if latest_error then
term.clearLine()
print("")
print(latest_error)
else
term.write("transferred so far: "..total_transferred.." ("..ips_rounded.." i/s) ")
end
if options.debug then
sleep(0)
else
local current_time = os.epoch("utc")/1000
time_to_wake = time_to_wake + 1
sleep(time_to_wake - current_time)
end
end
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","right","left"}) do
local p = peripheral.wrap(dir)
if p and p.getNameLocal then
self = p.getNameLocal()
return
end
end
-- could not find modem but it is a turtle, so here's a placeholder value
self = "self"
end
-- if we used turtle.select() anywhere during transfer
-- move it back to the original slot
local self_original_slot
local function self_save_slot()
if not self then return end
-- only save if we haven't saved already
-- this way we can just save before every turtle.select()
if not self_original_slot then
self_original_slot = turtle.getSelectedSlot()
end
end
local function self_restore_slot()
if not self then return end
if self_original_slot then
turtle.select(self_original_slot)
self_original_slot = nil
end
end
-- slot data structure:
-- chest_name: name of container holding that slot
-- slot_number: the index of that slot in the chest
-- name: name of item held in slot, nil if empty
-- nbt: nbt hash of item, nil if none
-- count: how much is there of this item, 0 if none
-- limit: how much of this item the slot can store, 64 for most items, 1 for unstackables
-- is_source: whether this slot matches source slot critera
-- is_dest: whether this slot matches dest slot criteria
-- cannot_wrap: the chest this slot is in cannot be wrapped
-- must_wrap: the chest this slot is in must be wrapped
-- after_action: identifies that some special action must be done after transferring to this slot
-- voided: how many of the items are physically there but are pretending to be missing
-- from_priority/to_priority: how early in the pattern match the chest appeared, lower number means higher priority
local function matches_filters(filters,slot,options)
if slot.name == nil then
error("SLOT NAME IS NIL")
end
local res = nil
if #filters == 0 then
res = true
else
res = false
for _,filter in pairs(filters) do
local match = true
if filter.name and not glob(filter.name,slot.name) then
match = false
end
if filter.nbt and not (slot.nbt and glob(filter.nbt,slot.nbt)) then
match = false
end
if match then
res = true
break
end
end
end
if options.negate then
return not res
else
return res
end
end
local limits_cache = {}
local no_c = {
list = function() return nil end,
size = function() return 0 end
}
local function chest_wrap(chest)
-- for every possible chest must have .list and .size
-- as well as returning cannot_wrap, must_wrap, and after_action
local cannot_wrap = false
local must_wrap = false
local after_action = false
if chest == "void" then
local c = {
list=function() return {} end,
size=function() return 1 end
}
cannot_wrap = true
must_wrap = true
after_action = true
return c, cannot_wrap, must_wrap, after_action
end
if chest == "self" then
cannot_wrap = true
local c = {
list = function()
local l = {}
for i=1,16 do
l[i] = turtle.getItemDetail(i,false)
if l[i] then
if limits_cache[l[i].name] == nil then
local details = turtle.getItemDetail(i,true)
l[i] = details
if details ~= nil then
limits_cache[details.name] = details.maxCount
end
end
if l[i] then
l[i].limit = limits_cache[l[i].name]
end
end
end
return l
end,
size = function() return 16 end
}
return c, cannot_wrap, must_wrap, after_action
end
if storages[chest] then
after_action = true
must_wrap = true
local c = storages[chest]
local cc = {
size = function() return 1+#c.list() end,
list = function()
local l = c.list()
for _,v in pairs(l) do
v.limit = 1/0
end
return l
end,
pushItems = c.pushItems,
pullItems = c.pullItems,
transfer = c.transfer
}
return cc, cannot_wrap, must_wrap, after_action
end
local c = peripheral.wrap(chest)
if not c then
--error("failed to wrap "..chest_name)
return no_c, cannot_wrap, must_wrap, after_action
end
if c.ejectDisk then
c.ejectDisk()
cannot_wrap = true
after_action = true
c.list = function() return {} end
c.size = function() return 1 end
return c, cannot_wrap, must_wrap, after_action
end
if c.getInventory then
-- this is actually a bound introspection module
must_wrap = true
local success
if options.ender then
success, c = pcall(c.getEnder)
else
success, c = pcall(c.getInventory)
end
if not success then
return no_c, cannot_wrap, must_wrap, after_action
end
end
if not c.list then
-- failed to wrap it for some reason
return no_c, cannot_wrap, must_wrap, after_action
end
local cc = {
list= function()
local l = c.list()
for i,item in pairs(l) do
if limits_cache[item.name] == nil then
-- 1.12 cc + plethora calls getItemDetail "getItemMeta"
if not c.getItemDetail then
c.getItemDetail = c.getItemMeta
end
local details = c.getItemDetail(i)
l[i] = details
if details ~= nil then
limits_cache[details.name] = details.maxCount
end
end
if l[i] then
l[i].limit = limits_cache[item.name]
end
end
return l
end,
size=c.size,
pullItems=c.pullItems,
pushItems=c.pushItems,
}
return cc, cannot_wrap, must_wrap, after_action
end
local function chest_list(chest)
local c, cannot_wrap, must_wrap, after_action = chest_wrap(chest)
return c.list(), cannot_wrap, must_wrap, after_action
end
local function chest_size(chest)
local c = chest_wrap(chest)
return c.size() or 0
end
local function transfer(from_slot,to_slot,count)
if count <= 0 then
return 0
end
if from_slot.chest_name == nil or to_slot.chest_name == nil then
error("NIL CHEST")
end
if storages[from_slot.chest_name] and storages[to_slot.chest_name] then
-- storage to storage transfer
return storages[from_slot.chest_name].transfer(storages[to_slot.chest_name],from_slot.name,from_slot.nbt,count)
end
if (not from_slot.cannot_wrap) and (not to_slot.must_wrap) then
local other_peripheral = to_slot.chest_name
if other_peripheral == "self" then other_peripheral = self end
local c = chest_wrap(from_slot.chest_name)
if not c then
return 0
end
local from_slot_number = from_slot.slot_number
local additional_info = nil
if storages[from_slot.chest_name] then
from_slot_number = from_slot.name..";"..from_slot.nbt
additional_info = {[to_slot.slot_number]={name=to_slot.name,nbt=to_slot.nbt,count=to_slot.count}}
end
return c.pushItems(other_peripheral,from_slot_number,count,to_slot.slot_number,additional_info)
end
if (not to_slot.cannot_wrap) and (not from_slot.must_wrap) then
local other_peripheral = from_slot.chest_name
if other_peripheral == "self" then other_peripheral = self end
local c = chest_wrap(to_slot.chest_name)
if not c then
return 0
end
local additional_info = nil
if storages[to_slot.chest_name] then
additional_info = {[from_slot.slot_number]={name=from_slot.name,nbt=from_slot.nbt,count=from_slot.count}}
end
return c.pullItems(other_peripheral,from_slot.slot_number,count,to_slot.slot_number,additional_info)
end
if to_slot.chest_name == "void" then
-- the void consumes all that you give it
return count
end
if from_slot.chest_name == "self" and to_slot.chest_name == "self" then
self_save_slot()
turtle.select(from_slot.slot_number)
-- this bs doesn't return how many items were moved
turtle.transferTo(to_slot.slot_number,count)
-- so we'll just trust that the math we used to get `count` is correct
return count
end
error("CANNOT DO TRANSFER BETWEEN "..from_slot.chest_name.." AND "..to_slot.chest_name)
end
local function mark_sources(slots,from,filters,options)
for _,s in ipairs(slots) do
if glob(from,s.chest_name) then
s.is_source = true
if options.from_slot then
local any_match = false
for _,slot in ipairs(options.from_slot) do
if type(slot) == "number" and s.slot_number == slot then
any_match = true
break
elseif type(slot) == "table" and slot[1] <= s.slot_number and s.slot_number <= slot[2] then
any_match = true
break
end
end
s.is_source = any_match
end
end
end
end
local function mark_dests(slots,to,filters,options)
for _,s in ipairs(slots) do
if glob(to,s.chest_name) then
s.is_dest = true
if options.to_slot then
local any_match = false
for _,slot in ipairs(options.to_slot) do
if type(slot) == "number" and s.slot_number == slot then
any_match = true
break
elseif type(slot) == "table" and slot[1] <= s.slot_number and s.slot_number <= slot[2] then
any_match = true
break
end
end
s.is_dest = any_match
end
end
end
end
local function unmark_overlap_slots(slots,options)
for _,s in ipairs(slots) do
if s.is_source and s.is_dest then
-- TODO: option to choose how this gets resolved
-- currently defaults to being dest
s.is_source = false
end
end
end
local function limit_slot_identifier(limit,primary_slot,other_slot)
local slot = {}
slot.chest_name = primary_slot.chest_name
slot.slot_number = primary_slot.slot_number
slot.name = primary_slot.name
slot.nbt = primary_slot.nbt
if other_slot == nil then other_slot = {} end
if slot.name == nil then
slot.name = other_slot.name
slot.nbt = other_slot.nbt
end
if slot.name == nil then
error("limit_slot_identifier was given two empty slots",2)
end
local identifier = ""
if limit.per_chest then
identifier = identifier..slot.chest_name
end
identifier = identifier..";"
if limit.per_slot then
if slot.chest_name ~= "void" and not storages[slot.chest_name] then
identifier = identifier..slot.slot_number
end
end
identifier = identifier..";"
if limit.per_name then
identifier = identifier..slot.name
end
identifier = identifier..";"
if limit.per_nbt then
identifier = identifier..(slot.nbt or "")
end
identifier = identifier..";"
if not limit.count_all then
if not matches_filters(filters,slot,options) then
identifier = identifier.."x"
end
end
return identifier
end
-- limit data structure
-- type: transfer/from/to
-- dir: direction; min/max for from/to
-- limit: the set amount that was specified to limit to
-- items: cache of item counts, indexed with an identifier
local function inform_limit_of_slot(limit,slot,options)
if slot.name == nil then return end
if limit.type == "transfer" then return end
if limit.type == "from" and (not slot.is_source) then return end
if limit.type == "to" and (not slot.is_dest) then return end
-- from and to limits follow
local identifier = limit_slot_identifier(limit,slot)
limit.items[identifier] = (limit.items[identifier] or 0) + slot.count
end
local function inform_limit_of_transfer(limit,from,to,amount,options)
local from_identifier = limit_slot_identifier(limit,from,to)
local to_identifier = limit_slot_identifier(limit,to,from)
if limit.items[from_identifier] == nil then
limit.items[from_identifier] = 0
end
if limit.items[to_identifier] == nil then
limit.items[to_identifier] = 0
end
if limit.type == "transfer" then
limit.items[from_identifier] = limit.items[from_identifier] + amount
if from_identifier ~= to_identifier then
if to.chest_name ~= "void" then
limit.items[to_identifier] = limit.items[to_identifier] + amount
end
end
elseif limit.type == "from" then
limit.items[from_identifier] = limit.items[from_identifier] - amount
elseif limit.type == "to" then
limit.items[to_identifier] = limit.items[to_identifier] + amount
else
error("UNKNOWN LIMT TYPE "..limit.type)
end
end
local function willing_to_give(slot,options)
if not slot.is_source then
return 0
end
if slot.name == nil then
return 0
end
local allowance = slot.count - (slot.voided or 0)
for _,limit in ipairs(options.limits) do
if limit.type == "from" then
local identifier = limit_slot_identifier(limit,slot)
limit.items[identifier] = limit.items[identifier] or 0
local amount_present = limit.items[identifier]
if limit.dir == "min" then
allowance = math.min(allowance, amount_present - limit.limit)
else
if amount_present > limit.limit then
allowance = 0
end
end
elseif limit.type == "transfer" then
local identifier = limit_slot_identifier(limit,slot)
limit.items[identifier] = limit.items[identifier] or 0
local amount_transferred = limit.items[identifier]
allowance = math.min(allowance, limit.limit - amount_transferred)
end
end
return math.max(allowance,0)
end
local function willing_to_take(slot,options,source_slot)
if not slot.is_dest then
return 0
end
local stack_size = limits_cache[source_slot.name]
if not stack_size and storages[source_slot.chest_name] then
-- FIXME: make a til method for this query
stack_size = storages[source_slot.chest_name].getStackSize(source_slot.name)
end
local allowance
if storages[slot.chest_name] then
-- fake slot from a til storage
-- TODO: implement limits for storages (at least transfer limits)
storages[slot.chest_name].informStackSize(source_slot.name,stack_size)
allowance = storages[slot.chest_name].spaceFor(source_slot.name, source_slot.nbt)
elseif slot.chest_name == "void" then
-- fake void slot, infinite limit
allowance = slot.limit
else
-- real regular slot
allowance = math.min(slot.limit,stack_size or (1/0)) - slot.count
end
for _,limit in ipairs(options.limits) do
if limit.type == "to" then
local identifier = limit_slot_identifier(limit,slot,source_slot)
limit.items[identifier] = limit.items[identifier] or 0
local amount_present = limit.items[identifier]
if limit.dir == "max" then
allowance = math.min(allowance, limit.limit - amount_present)
else
if amount_present < limit.limit then
allowance = 0
end
end
elseif limit.type == "transfer" then
local identifier = limit_slot_identifier(limit,slot,source_slot)
limit.items[identifier] = limit.items[identifier] or 0
local amount_transferred = limit.items[identifier]
allowance = math.min(allowance, limit.limit - amount_transferred)
end
end
return math.max(allowance,0)
end
local function sort_sources(sources)
table.sort(sources, function(left, right)
if left.from_priority ~= right.from_priority then
return left.from_priority < right.from_priority
elseif left.count - (left.voided or 0) ~= right.count - (right.voided or 0) then
return left.count - (left.voided or 0) < right.count - (right.voided or 0)
elseif left.chest_name ~= right.chest_name then
return left.chest_name < right.chest_name
elseif left.slot_number ~= right.slot_number then
return left.slot_number > right.slot_number -- TODO: make this configurable
elseif left.name ~= right.name then
return left.name < right.name
elseif left.nbt ~= right.nbt then
return left.nbt < right.nbt
end
end)
end
local function sort_dests(dests)
table.sort(dests, function(left, right)
if left.to_priority ~= right.to_priority then
return left.to_priority < right.to_priority
elseif (left.limit - left.count) ~= (right.limit - right.count) then
return (left.limit - left.count) < (right.limit - right.count)
elseif left.chest_name ~= right.chest_name then
return left.chest_name < right.chest_name
elseif left.slot_number ~= right.slot_number then
return left.slot_number < right.slot_number -- different here
elseif left.name ~= right.name then
if left.name == nil then
return false
end
if right.name == nil then
return true
end
return left.name < right.name
elseif left.nbt ~= right.nbt then
return left.nbt < right.nbt
end
end)
end
local function after_action(d,s,transferred,dests,di)
if d.chest_name == "void" then
s.count = s.count + d.count
s.voided = (s.voided or 0) + d.count
d.count = 0
d.name = nil
d.nbt = nil
d.limit = 1/0
return
end
if storages[d.chest_name] then
if d.count == transferred then
-- TODO: make new empty slots instead of resetting the empty slot
-- make new empty slot now that this one isn't
local dd = {}
for k,v in pairs(d) do
dd[k] = v
end
dd.count = 0
dd.name = nil
dd.nbt = nil
dd.limit = 1/0
dd.slot_number = d.slot_number + 1
-- insert it right after the empty slot that just got filled
table.insert(dests, di+1, dd)
end
return
end
local c = peripheral.wrap(d.chest_name)
if c.ejectDisk then
c.ejectDisk()
d.count = 0
return
end
error(d.chest_name.." does not have an after_action")
end
local coroutine_lock = false
local function hopper_step(from,to,peripherals,my_filters,my_options,retrying_from_failure)
filters = my_filters
options = my_options
hoppering_stage = "scan"
for _,limit in ipairs(options.limits) do
if retrying_from_failure and limit.type == "transfer" then
-- don't reset it
else
limit.items = {}
end
end
local slots = {}
for _,p in ipairs(peripherals) do
local l, cannot_wrap, must_wrap, after_action_bool = chest_list(p)
if l ~= nil then
local _,from_priority = glob(from,p)
local _,to_priority = glob(to,p)
for i=1,chest_size(p) do
local slot = {}
slot.chest_name = p
slot.slot_number = i
slot.is_source = false
slot.is_dest = false
slot.cannot_wrap = cannot_wrap
slot.must_wrap = must_wrap
slot.after_action = after_action_bool
slot.from_priority = from_priority
slot.to_priority = to_priority
if l[i] == nil then
slot.name = nil
slot.nbt = nil
slot.count = 0
slot.limit = 1/0
else
slot.name = l[i].name
slot.nbt = l[i].nbt
slot.count = l[i].count
slot.limit = l[i].limit
end
table.insert(slots,slot)
end
end
end
hoppering_stage = "mark"
mark_sources(slots,from,filters,options)
mark_dests(slots,to,filters,options)
unmark_overlap_slots(slots,options)
for _,slot in ipairs(slots) do
for _,limit in ipairs(options.limits) do
inform_limit_of_slot(limit, slot,options)
end
end
local sources = {}
local dests = {}
for _,s in pairs(slots) do
if s.is_source then
if s.count > (s.voided or 0) then
table.insert(sources,s)
end
elseif s.is_dest then
if s.limit > s.count then
table.insert(dests,s)
end
end
end
if #sources == 0 or #dests == 0 then
options = nil
filters = nil
hoppering_stage = nil
return
end
hoppering_stage = "sort"
sort_sources(sources)
sort_dests(dests)
-- TODO: implement O(n) algo from TIL into here
hoppering_stage = "transfer"
for si,s in pairs(sources) do
if s.name ~= nil and matches_filters(filters,s,options) then
local sw = willing_to_give(s,options)
for di,d in pairs(dests) do
if sw == 0 then
break
end
if not options.preserve_slots or s.slot_number == d.slot_number then
if d.name == nil or (s.name == d.name and (s.nbt or "") == (d.nbt or "")) then
local dw = willing_to_take(d,options,s)
local to_transfer = math.min(sw,dw)
to_transfer = to_transfer - (to_transfer % (options.batch_multiple or 1))
if to_transfer < (options.min_batch or 0) then
to_transfer = 0
end
if to_transfer > 0 then
--FIXME: propagate errors up correctly
--local success,transferred = pcall(transfer,s,d,to_transfer)
local success = true
local transferred = transfer(s,d,to_transfer)
if not success or transferred ~= to_transfer then
-- something went wrong, rescan and try again
if not success then
transferred = 0
end
total_transferred = total_transferred + transferred
hoppering_stage = nil
return hopper_step(from,to,peripherals,my_filters,my_options,true)
end
s.count = s.count - transferred
d.count = d.count + transferred
-- relevant if d was empty
d.name = s.name
d.nbt = s.nbt
d.limit = s.limit
if d.after_action then
after_action(d, s, transferred, dests, di)
end
-- relevant if s became empty
if s.count == 0 then
s.name = nil
s.nbt = nil
s.limit = 1/0
end
total_transferred = total_transferred + transferred
for _,limit in ipairs(options.limits) do
inform_limit_of_transfer(limit,s,d,transferred,options)
end
sw = willing_to_give(s,options)
end
end
end
end
end
end
self_restore_slot()
options = nil
filters = nil
hoppering_stage = nil
end
-- returns list of storage objects and peripheral blacklist
local function create_storage_objects(storage_options)
local peripherals = peripheral.getNames()
for _,o in pairs(storage_options) do
local chests = {}
for i,c in pairs(peripherals) do
if glob(o.pattern, c) and not peripheral_blacklist[c] then
table.insert(chests,c)
peripheral_blacklist[c] = true
peripherals[i] = nil
end
end
local storage = til.new(chests)
storages[o.name] = storage
end
end
local function hopper_loop(commands,options)
options = default_options(options)
create_storage_objects(options.storages)
local time_to_wake = nil
while true do
for _,command in ipairs(commands) do
local from = command.from
local to = command.to
if not from then
error("NO 'FROM' PARAMETER SUPPLIED")
end
if not to then
error ("NO 'TO' PARAMETER SUPPLIED ('from' is "..from..")")
end
determine_self()
local peripherals = {}
table.insert(peripherals,"void")
if self then
table.insert(peripherals,"self")
end
for p,_ in pairs(storages) do
if (glob(from,p) or glob(to,p)) then
table.insert(peripherals,p)
end
end
for _,p in ipairs(peripheral.getNames()) do
if (glob(from,p) or glob(to,p)) and not peripheral_blacklist[p] then
table.insert(peripherals,p)
end
end
while coroutine_lock do coroutine.yield() end
-- multiple hoppers running in parallel
-- but within the same lua script can clash horribly
coroutine_lock = true
local success, error_msg = pcall(hopper_step,command.from,command.to,peripherals,command.filters,command.options)
--hopper_step(command.from,command.to,peripherals,command.filters,command.options)
coroutine_lock = false
if not success then
latest_error = error_msg -- TODO: reset this to nil later
if options.once then
error(error_msg)
end
end
end
if options.once then
break
end
local current_time = os.epoch("utc")/1000
time_to_wake = (time_to_wake or current_time) + options.sleep
sleep(time_to_wake - current_time)
end
end
local function hopper_parser_singular(args)
local from = nil
local to = nil
local options = {}
options.limits = {}
options.storages = {}
local filters = {}
local i=1
while i <= #args do
if glob("-*",args[i]) then
if args[i] == "-once" then
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] == "-debug" then
options.debug = true
elseif args[i] == "-negate" or args[i] == "-negated" or args[i] == "-not" then
options.negate = true
elseif args[i] == "-nbt" then
i = i+1
filters[#filters].nbt = args[i]
elseif args[i] == "-from_slot" then
i = i+1
if options.from_slot == nil then
options.from_slot = {}
end
table.insert(options.from_slot,tonumber(args[i]))
elseif args[i] == "-from_slot_range" then
i = i+2
if options.from_slot == nil then
options.from_slot = {}
end
table.insert(options.from_slot,{tonumber(args[i-1]),tonumber(args[i])})
elseif args[i] == "-to_slot" then
i = i+1
if options.to_slot == nil then
options.to_slot = {}
end
table.insert(options.to_slot,tonumber(args[i]))
elseif args[i] == "-to_slot_range" then
i = i+2
if options.to_slot == nil then
options.to_slot = {}
end
table.insert(options.to_slot,{tonumber(args[i-1]),tonumber(args[i])})
elseif args[i] == "-preserve_slots" or args[i] == "-preserve_order" then
options.preserve_slots = true
elseif args[i] == "-min_batch" or args[i] == "-batch_min" then
i = i+1
options.min_batch = tonumber(args[i])
elseif args[i] == "-max_batch" or args[i] == "-batch_max" then
i = i+1
table.insert(options.limits, { type="transfer", limit=tonumber(args[i]) } )
options.limits[#options.limits].per_slot = true
options.limits[#options.limits].per_chest = true
elseif args[i] == "-batch_multiple" then
i = i+1
options.batch_multiple = tonumber(args[i])
elseif args[i] == "-from_limit_min" or args[i] == "-from_limit" then
i = i+1
table.insert(options.limits, { type="from", dir="min", limit=tonumber(args[i]) } )
elseif args[i] == "-from_limit_max" then
i = i+1
table.insert(options.limits, { type="from", dir="max", limit=tonumber(args[i]) } )
elseif args[i] == "-to_limit_min" then
i = i+1
table.insert(options.limits, { type="to", dir="min", limit=tonumber(args[i]) } )
elseif args[i] == "-to_limit_max" or args[i] == "-to_limit" then
i = i+1
table.insert(options.limits, { type="to", dir="max", limit=tonumber(args[i]) } )
elseif args[i] == "-refill" then
table.insert(options.limits, { type="to", dir="min", limit=1 } )
options.limits[#options.limits].per_name = true
options.limits[#options.limits].per_chest = true
elseif args[i] == "-transfer_limit" then
i = i+1
table.insert(options.limits, { type="transfer", limit=tonumber(args[i]) } )
elseif args[i] == "-per_slot" then
options.limits[#options.limits].per_slot = true
options.limits[#options.limits].per_chest = true
elseif args[i] == "-per_chest" then
options.limits[#options.limits].per_chest = true
elseif args[i] == "-per_slot_number" then
options.limits[#options.limits].per_slot = true
elseif args[i] == "-per_item" then
options.limits[#options.limits].per_name = true
elseif args[i] == "-per_nbt" then
options.limits[#options.limits].per_name = true
options.limits[#options.limits].per_nbt = true
elseif args[i] == "-count_all" then
options.limits[#options.limits].count_all = true
elseif args[i] == "-alias" then
i = i+2
if not is_valid_name(args[i-1]) then
error("Invalid name for -alias: "..args[i-1])
end
table.insert(aliases,{name=args[i-1],pattern=args[i]})
elseif args[i] == "-storage" then
i = i+2
if not is_valid_name(args[i-1]) then
error("Invalid name for -storage: "..args[i-1])
end
table.insert(options.storages,{name=args[i-1], pattern=args[i]})
elseif args[i] == "-alias" then
-- TODO
error("-alias is not implemented yet")
elseif args[i] == "-sleep" then
i = i+1
options.sleep = tonumber(args[i])
elseif args[i] == "-ender" then
options.ender = true
else
error("UNKNOWN ARGUMENT: "..args[i])
end
else
if not from then
from = args[i]
elseif not to then
to = args[i]
else
table.insert(filters, {name=args[i]})
end
end
i = i+1
end
return from,to,filters,options
end
-- returns: {from,to,filters,options}[], options
local function hopper_parser(args)
table.insert(args,"/") -- end the last command with `/`, otherwise it might get missed
local global_options
local commands = {}
local token_list = {}
for _,token in ipairs(args) do
-- TODO: comments on `//`
-- will probably involve better parsing
if token == "/" then
if #token_list > 0 then
-- end of command, parse it and start a new one
local from,to,filters,options = hopper_parser_singular(token_list)
if from then
table.insert(commands,{from=from,to=to,filters=filters,options=options})
end
if not global_options then
global_options = options
end
token_list = {}
end
else
-- insert token into token_list for parsing
table.insert(token_list, token)
end
end
args[#args] = nil -- remove the `/` we added earlier
return commands, global_options
end
local function hopper_main(args, is_lua)
local commands,options = hopper_parser(args)
if is_lua then
if options.once == nil then
options.once = true
end
if options.quiet == nil then
options.quiet = true
end
end
local args_string = table.concat(args," ")
local function displaying()
display_loop(options,args_string)
end
local function transferring()
hopper_loop(commands,options)
end
total_transferred = 0
exitOnTerminate(function()
parallel.waitForAny(transferring, displaying)
end)
display_exit(options,args_string)
return total_transferred
end
local function hopper(args_string)
local args = {}
for arg in args_string:gmatch("%S+") do
table.insert(args, arg)
end
return hopper_main(args, true)
end
local function isImported()
-- https://stackoverflow.com/questions/49375638/how-to-determine-whether-my-code-is-running-in-a-lua-module
return pcall(debug.getlocal, 4, 1)
end
local function main(args)
-- this nonsense is here to handle newlines
-- it might be better to just hand hopper_main() the joint string, though.
local args_string = table.concat(args, " ")
args = {}
for arg in args_string:gmatch("%S+") do
table.insert(args, arg)
end
if isImported() then
local exports = {
hopper=hopper,
version=version,
storages=storages
}
setmetatable(exports,{
_G=_G,
__call=function(self, args) return hopper(args) end
})
return exports
end
if #args < 2 then
print(help_message)
return
end
hopper_main(args)
end
til = load([==[ -- Copyright umnikos (Alex Stefanov) 2024
-- Licensed under MIT license
local version = "0.12"
-- defined at the end
local exports
-- cache of stack sizes, name -> number
local stack_sizes = {}
local function splitIdent(ident)
local i = string.find(ident,";")
local nbt = string.sub(ident,i+1)
local name = string.sub(ident,0,i-1)
return name,nbt
end
-- returns a list of {name,nbt,count}
local function list(inv)
local l = {}
for k,v in pairs(inv.items) do
local name,nbt = splitIdent(k)
local count = v.count
table.insert(l,{name=name,nbt=nbt,count=count})
end
return l
end
-- inform the storage of the stack size of an item it has not seen yet
-- DO NOT LIE! (even if it's convenient)
local function informStackSize(name,stacksize)
stack_sizes[name] = stacksize
end
local function getStackSize(name)
return stack_sizes[name]
end
-- additional amounts of that item the storage is able to store
local function spaceFor(inv,name,nbt)
-- partial slots
local stacksize = stack_sizes[name]
if not stacksize then
return nil
end
local ident = name..";"..(nbt or "")
local partials = inv.items[ident] or {slots={},count=0}
local partial_slot_space = (#partials.slots)*stacksize - partials.count
local empty_slot_space = (#inv.empty_slots)*stacksize
return partial_slot_space + empty_slot_space
end
-- amount of a particular item in storage
local function amountOf(inv,name,nbt)
local ident = name..";"..(nbt or "")
if not inv.items[ident] then
return 0
end
return inv.items[ident].count
end
-- transfer from one storage to another
local function transfer(inv1,inv2,name,nbt,amount)
local stacksize = stack_sizes[name]
if not stacksize then
error("Unknown stack size?!?")
end
local ident = name..";"..(nbt or "")
inv1.items[ident] = inv1.items[ident] or {count=0,slots={},first_partial=1}
inv2.items[ident] = inv2.items[ident] or {count=0,slots={},first_partial=1}
local sources = inv1.items[ident].slots
local sl = #sources
local dests_partial = inv2.items[ident].slots
local dlp = #dests_partial
local dests_empty = inv2.empty_slots
local dle = #dests_empty
local si = sl
local di = inv2.items[ident].first_partial
local transferred = 0
local s
local d
while amount > 0 and si >= 1 and di <= (dlp+dle) do
if not s then
s = sources[si]
end
if not d then
if di <= dlp then
d = dests_partial[di]
else
d = dests_empty[dle-(di-dlp)+1]
end
end
if not s or s.count <= 0 then
si = si - 1
s = nil
elseif not d or d.count >= stacksize then
di = di + 1
d = nil
else
local to_transfer = math.min(amount, s.count, stacksize-d.count)
local real_transfer = peripheral.wrap(s.chest).pushItems(d.chest,s.slot,to_transfer,d.slot)
-- we will work with the real transfer amount
-- if it doesn't match the planned amount we'll error *after* updating everything
-- because if only one of the storages is inconsistent we want to maintain consistency on the other one
transferred = transferred + real_transfer
amount = amount - real_transfer
s.count = s.count - real_transfer
inv1.items[ident].count = inv1.items[ident].count - real_transfer
if s.count == 0 then
-- source is an empty slot now
table.insert(inv1.empty_slots,s)
inv1.items[ident].slots[si] = nil
end
if s.count < stacksize then
-- source is not a full slot now
inv1.items[ident].first_partial = si
end
d.count = d.count + real_transfer
if di <= dlp then
if d.count >= stacksize then
-- dest is a full slot now
inv2.items[ident].first_partial = di+1
end
else
if d.count > 0 then
-- dest is not an empty slot now
table.insert(inv2.items[ident].slots,d)
inv2.empty_slots[dle-(di-dlp)+1] = nil
end
end
inv2.items[ident].count = inv2.items[ident].count + real_transfer
if to_transfer ~= real_transfer then
error("Inconsistency detected during ail transfer")
end
end
end
return transferred
end
-- transfer from a chest
-- from_slot is a required argument (might change in the future)
-- to_slot does not exist as an argument, if passed it'll simply be ignored
-- list_cache is optionally a .list() of the source chest
local function pullItems(inv,chest,from_slot,amount,_to_slot,list_cache)
if type(from_slot) ~= "number" then
error("from_slot is a required argument")
end
local l = list_cache or peripheral.wrap(chest.list)
local s = l[from_slot]
local inv2 = exports.new({chest},1,{[chest]=l},from_slot)
return inv2.transfer(inv,s.name,s.nbt,amount)
end
-- transfer to a chest
-- from_slot is a required argument, and determines the type of item transferred
-- if from_slot is a number it will transfer the type of item at that entry in inv.list()
-- if from_slot is a "name;nbt" string then it'll transfer that type of item
-- list_cache is optionally a .list() of the destination chest
local function pushItems(inv,chest,from_slot,amount,to_slot,list_cache)
local name,nbt
if type(from_slot) == "number" then
local l = inv.list()
if l[from_slot] then
name = l[from_slot].name
nbt = l[from_slot].nbt
end
elseif type(from_slot) == "string" then
name,nbt = splitIdent(from_slot)
end
if not name then
error("item name is nil")
end
if not nbt then nbt = "" end
local inv2 = exports.new({chest},1,{[chest]=list_cache},to_slot)
return inv.transfer(inv2,name,nbt,amount)
end
-- create an inv object out of a list of chests
local function new(chests, indexer_threads,list_cache,slot_number)
if not list_cache then list_cache = {} end
indexer_threads = math.min(indexer_threads or 32, #chests)
local inv = {}
-- list of chest names
inv.chests = chests
-- name;nbt -> total item count + list of slots with counts
inv.items = {}
-- list of empty slots
inv.empty_slots = {}
do -- index chests
local chestsClone = {}
for _,v in ipairs(chests) do
chestsClone[#chestsClone+1] = v
end
local function indexerThread()
while true do
if #chestsClone == 0 then return end
local cname = chestsClone[#chestsClone]
chestsClone[#chestsClone] = nil
local c = peripheral.wrap(cname)
-- 1.12 cc + plethora calls getItemDetail "getItemMeta"
if not c.getItemDetail then
c.getItemDetail = c.getItemMeta
end
local l = list_cache[cname] or c.list()
local size = slot_number or c.size()
for i = (slot_number or 1),size do
local item = l[i]
if not item or not item.name then
-- empty slot
table.insert(inv.empty_slots,{count=0,chest=cname,slot=i})
else
-- slot with an item
local nbt = item.nbt or ""
local name = item.name
local count = item.count
local ident = name..";"..nbt -- identifier
inv.items[ident] = inv.items[ident] or {count=0,slots={},first_partial=1}
inv.items[ident].count = inv.items[ident].count + count
table.insert(inv.items[ident].slots,{count=count,chest=cname,slot=i})
-- inform stack sizes cache if it doesn't know this item
-- this is slow but it's only done once per item type
if not stack_sizes[name] then
stack_sizes[name] = c.getItemDetail(i).maxCount
end
end
end
end
end
local threads = {}
for i=1, indexer_threads do
threads[#threads+1] = indexerThread
end
parallel.waitForAll(table.unpack(threads))
end
-- add methods to the inv
inv.informStackSize = informStackSize
inv.getStackSize = getStackSize
inv.spaceFor = function(name,nbt) return spaceFor(inv,name,nbt) end
inv.amountOf = function(name,nbt) return amountOf(inv,name,nbt) end
inv.transfer = function(inv2,name,nbt,amount) return transfer(inv,inv2,name,nbt,amount) end
inv.pushItems = function(chest,from_slot,amount,to_slot,list_cache) return pushItems(inv,chest,from_slot,amount,to_slot,list_cache) end
inv.pullItems = function(chest,from_slot,amount,_to_slot,list_cache) return pullItems(inv,chest,from_slot,amount,_to_slot,list_cache) end
inv.list = function() return list(inv) end
return inv
end
exports = {
version=version,
new=new
}
return exports ]==])()
return main({...})