bans-automata/automata/storage-bot.lua
2024-02-10 14:08:21 +00:00

720 lines
21 KiB
Lua

-- _____ _ ____ _
-- / ____| | | _ \ | |
-- | (___ | |_ ___ _ __ __ _ __ _ ___| |_) | ___ | |_
-- \___ \| __/ _ \| '__/ _` |/ _` |/ _ \ _ < / _ \| __|
-- ____) | || (_) | | | (_| | (_| | __/ |_) | (_) | |_
-- |_____/ \__\___/|_| \__,_|\__, |\___|____/ \___/ \__|
-- __/ |
-- |___/
-- sstorage-bot.lua
-- Copyright (C) 2023, Blake Rain.
-- Licensed under BSD3 License. See LICENSE for details.
--
-- A simple storage bot that works with the Storage Drawers mod.
--
-- This was created after I found that the Starbuncles (from Ars Nouveau mod) were not really able
-- to deal with my storage situation.
--
-- The bot is configured with a storage area: a bounding-box in which there should be one or more
-- storage drawers. A constraint is placed on these storage drawers that they should be accessible
-- from either the east or west sides.
--
-- The bot resides next to both its input and fuel inventories. The input inventory is placed on
-- the left of the bot, and the fuel inventory is placed infront. Every minute, the bot checks the
-- input inventory for items and, if there are any, extracts as much as it can carry. The bot will
-- then visit the corresponding storage drawer for each item, placing the items into storage. Once
-- completed, the bot will return to its resting location and check the input inventory. If there
-- are more items the process of placing the items into storage will begin again; otherwise the
-- bot will go back to sleep for another minute.
--
-- In order to build a map of all the stoarge drawers in the region, the bot can be run with the
-- "scan" argument. This will cause the bot to walk through the entire area, noting down the
-- location and contents of any storage drawers it finds. Note that this process can take a very
-- long time for larger areas, as the bot needs to be thorough.
--
-- Whilst the bot is looking around for storage drawers, it is also building a mantal map of the
-- area, which is stored in the AA graph. This is then used along with the A* algorithm to allow
-- the bot to pathfind to each storage drawer.
--
-- The bot can also be run with the "update" argument. This will cause the bot to revisit all the
-- storage drawers it knows about and update its knowledge of their contents. This is useful if
-- you rearrange the contents of the storage drawers, but do not change their spacial
-- configuration such that a new "scan" needs to be run. It is much more efficient to just visit
-- all the known storage drawers and check their contents.
--
-- The bot creates to files:
--
-- - storage-bot.log which contains all the log output from the bot. Any errors and so on will
-- be recorded here.
-- - .storage-bot.data contains a Lua-serialized dump of the bot's AA and other internals that
-- were constructed during the "scan" process. This file, which can get quite large, contains
-- all the bots memories.
--
-- [2023-11-12] Initial version
-- [2023-11-14] Optimize (and simplify) initial area scanning
-- [2023-11-14] Improve scanning of area by not turning to storage drawers
-- [2023-11-15] Added the "update" command
-- [2024-01-20] Now uses Utamacraft area awareness block to scan for drawers on 'update'
package.path = "/?.lua;/?/init.lua;" .. package.path
local Assert = require("lib.assert")
local AABB = require("lib.aabb")
local AANode = require("lib.bot.aa.node")
local AAUtils = require("lib.bot.aa.utils")
local Bot = require("lib.bot")
local Log = require("lib.log")
local Direction = require("lib.direction")
local String = require("lib.string")
local Table = require("lib.table")
local Vector = require("lib.vector")
local class = require("lib.class")
Log.setLogFile("storage-bot.log", true)
-----------------------------------------------------------------------------------------------
local Drawer = class("Drawer")
function Drawer:init(index, pos, dir, side)
Assert.assertIs(index, "number")
Assert.assertInstance(pos, Vector)
Direction.assertDir(dir)
self.index = index
self.pos = pos
self.dir = dir
self.size = 0
self.slots = {}
if side ~= nil then
local ok, err = self:update(side)
if not ok then
return nil, err
end
end
end
function Drawer.static.deserialize(data)
Assert.assertIs(data, "table")
Assert.assertIs(data.index, "number")
Assert.assertIs(data.dir, "number")
Assert.assertIs(data.size, "number")
Assert.assertIs(data.slots, "table")
local drawer = Drawer:new(data.index, Vector.deserialize(data.pos), data.dir)
drawer.size = data.size
drawer.slots = data.slots
return drawer
end
function Drawer:serialize()
return {
index = self.index,
pos = self.pos:serialize(),
dir = self.dir,
size = self.size,
slots = self.slots,
}
end
function Drawer:__tostring()
return ("%s"):format(self.pos)
end
function Drawer:getBotCoord()
-- Get the opposite direction that the bot faces for this drawer, then move in that direction
-- by one block. This gives us the location the bot should pathfind to for the drawer.
return Direction.offsetDirection(self.pos, Direction.opposite(self.dir), 1)
end
function Drawer:update(side)
local drawer = peripheral.wrap(side)
if not drawer then
Log.error(("Unable to wrap peripheral on %s side of bot"):format(side))
return false, "Unable to wrap peripheral on " .. side
end
self.size = drawer.size()
self.slots = {}
for slot, item in pairs(drawer.list()) do
self.slots[slot] = item.name
end
return true
end
-----------------------------------------------------------------------------------------------
local function createArea()
local area = AABB:new()
area:addPoint(-4, 1, -19)
area:addPoint(11, 3, -1)
return area
end
-----------------------------------------------------------------------------------------------
local StoreBot = class("StoreBot")
StoreBot.static.MIN_FUEL = 2000
function StoreBot:init()
self.area = createArea()
self.bot = Bot:new(Direction.North)
self.home = self.bot.pos:clone()
self.drawers = {}
self.items = {}
self.digitized = {}
end
-----------------------------------------------------------------------------------------------
function StoreBot:save()
local drawers = {}
for _, drawer in ipairs(self.drawers) do
table.insert(drawers, drawer:serialize())
end
local file = fs.open(".storage-bot.data", "w")
file.write(textutils.serialize({
bot = self.bot:serialize(),
home = self.home:serialize(),
drawers = drawers,
}))
file.close()
end
function StoreBot:load()
local file = fs.open(".storage-bot.data", "r")
local text = file.readAll()
local data = textutils.unserialize(text)
file.close()
if not data then
Log.error("Failed to read storage bot data")
return nil, "Failed to read storage bot data"
end
Assert.assertIs(data, "table")
local bot = StoreBot:new()
bot.area = createArea()
bot.bot = Bot.deserialize(data.bot)
bot.home = Vector.deserialize(data.home)
local ndrawers = 0
local nitems = 0
for index, drawer_spec in ipairs(data.drawers) do
local drawer = Drawer.deserialize(drawer_spec)
for _, item in pairs(drawer.slots) do
if item then
bot.items[item] = index
nitems = nitems + 1
end
end
table.insert(bot.drawers, drawer)
ndrawers = ndrawers + 1
end
Log.info(("Loaded %d drawers with %d items"):format(ndrawers, nitems))
return bot
end
function StoreBot:addLocationsForDrawer(drawer)
Assert.assertInstance(drawer, Drawer)
local nitems = 0
for _, item in pairs(drawer.slots) do
if item then
self.items[item] = drawer.index
nitems = nitems + 1
end
end
return nitems
end
function StoreBot:selectNextFreeSlot(start)
local slot = start or 1
while slot <= 16 do
local info = turtle.getItemDetail(slot)
if info == nil then
turtle.select(slot)
return slot
end
slot = slot + 1
end
return 0
end
function StoreBot:remainingFreeSlots()
local remaining = 0
for slot = 1, 16 do
local info = turtle.getItemDetail(slot)
if not info then
remaining = remaining + 1
end
end
return remaining
end
-----------------------------------------------------------------------------------------------
function StoreBot:receiveFuel()
-- Check to see if we need fuel
local level = turtle.getFuelLevel()
if level == "unlimited" then
Log.info("Bot is a creative bot that has unlimited fuel")
return true
end
-- See what is in front of us using the AA
local infront = self.bot:queryForward()
if infront.state ~= AANode.FULL then
Log.error("Unable to find block for fuel inventory infront of bot")
return false, "No fuel inventory"
end
-- Make sure that what is in front of us is something that has an inventory
if not String.startsWith(infront.info.name, "storagedrawers:") then
Log.error("Unknown field inventory infront of block:", infront.info.name)
return false, "Unknown fuel inventory"
end
-- Select the bot's fuel slot and then receive up to a stack of fuel from the inventory.
turtle.select(self.bot.fuelSlot)
turtle.suck()
-- Examine what we received in our fuel slot
local info = turtle.getItemDetail(self.bot.fuelSlot)
if info == nil then
Log.error("No items found in fuel slot after refueling")
turtle.select(1)
return false, "No fuel received"
end
-- Use the fuel to refuel the bot
local ok, err = turtle.refuel()
if not ok then
Log.error("Failed to refuel bot:", err)
turtle.select(1)
return false, "Unable to refuel bot"
end
local new_level = turtle.getFuelLevel()
Log.info(("Refuelled bot by %d (current level: %d)"):format(new_level - level, new_level))
return true
end
function StoreBot:refuelIfNeeded()
local fuel_level = turtle.getFuelLevel()
if fuel_level == "unlimited" then
Log.info("Bot is a creative bot that has unlimited fuel")
return true
end
if fuel_level < StoreBot.MIN_FUEL then
Log.info("Bot fuel level is low, attempting to refuel")
return self:receiveFuel()
-- else
-- Log.info(("Bot fuel level %d is above minimum %d"):format(fuel_level, StoreBot.MIN_FUEL))
end
return true
end
-----------------------------------------------------------------------------------------------
function StoreBot:update()
-- Move to the awareness block, situated in the midst of our storage
self.bot:move(Direction.DirSeq:new():up(3):north(8):east(5):north(2):up(1):west(0))
-- Perform a scan using the awareness block. This will return to us a load of blocks.
local scanner = peripheral.wrap("front")
local scan, err = scanner.scan(8, "down")
if not scan then
Log.error(("Failed to perform scan: %s"):format(err))
return false, err
end
Log.info(("Scan returned %d blocks and cost %d energy"):format(#scan.blocks, scan.energy.cost))
-- Build a dictionary of our drawers, indexed by their coordinates
local drawers = {}
for _, drawer in ipairs(self.drawers) do
drawers[tostring(drawer.pos)] = drawer
end
local function relative(block)
block.x = block.x + 4
block.y = block.y + 4
block.z = block.z - 10
end
local updated = 0
for _, block in ipairs(scan.blocks) do
if block.name:match("storagedrawers:.*") then
-- Make the block's coordinates relative to our home position, as per the AA
relative(block)
-- Get the drawer at this coordinate
local pos = Vector:new(block.x, block.y, block.z)
local drawer = drawers[tostring(pos)]
if drawer then
drawer.size = block.inventory.size
drawer.slots = {}
for slot = 2, block.inventory.size do
local info = block.inventory.slots[slot]
if info.name ~= "minecraft:air" and info.count > 0 then
drawer.slots[slot] = info.name
end
end
updated = updated + 1
else
Log.error(("Failed to find drawer at %d:%d:%d"):format(block.x, block.y, block.z))
end
end
end
Log.info(("Updated %d drawers"):format(updated))
-- Move back to our home location
self.bot:move(Direction.DirSeq:new():down(1):south(2):west(5):south(8):down(3):north(0))
self:save()
return true
end
-----------------------------------------------------------------------------------------------
function StoreBot:targetBlockInRange(block)
return self.area:contains(block.x, block.y, block.z)
end
function StoreBot:scanSurrounding()
local result = {}
local queries = {
{ self.bot:queryForward(false), self.bot.dir },
{ self.bot:queryLeft(false), self.bot:leftDirection() },
{ self.bot:queryRight(false), self.bot:rightDirection() },
{ self.bot:queryUp(), Direction.Up },
{ self.bot:queryDown(), Direction.Down },
}
for _, query in ipairs(queries) do
local node, direction = table.unpack(query)
if node.state == AANode.EMPTY then
local target = Direction.offsetDirection(self.bot.pos, direction, 1)
if self:targetBlockInRange(target) then
table.insert(result, target)
end
end
end
return result
end
local function isStorageDrawer(item)
return String.startsWith(item.name, "storagedrawers:")
end
function StoreBot:scan()
local ok, err
-- Make sure that we have enough fuel
ok, err = self:refuelIfNeeded()
if not ok then
return false, "Failed to refuel bot"
end
-- Move up from our starting position
ok, err = self.bot:up(2)
if not ok then
return false, "Failed to move up"
end
-- We maintain a stack of empty locations to visit. These are the locations in our AA in
-- which we've found air (or whatever) into which the bot can move. Each entry in the
-- stack is the location of the block.
local stack = {}
local visited = {}
local found = {}
-- Push the initial scan onto the stack
Table.concat(stack, self:scanSurrounding())
while #stack > 0 do
-- Get the target block from the top of the stack.
local target = table.remove(stack, #stack)
-- Move the bot into the target block
ok, err = self.bot:pathFind(target, 200)
if not ok then
Log.error(("Failed to pathfind to block at %s: %s"):format(target, err))
return false, err
end
-- Record this block in the "visited" set
visited[AAUtils.positionKey(target)] = true
for _, direction in ipairs({ Direction.East, Direction.West }) do
-- See if there is a node there that looks like a storage drawer
local node = self.bot:query(direction)
if node.state == AANode.FULL and isStorageDrawer(node.info) then
local drawer_pos = self.bot:relativePosition(direction)
local drawer_key = AAUtils.positionKey(drawer_pos)
-- See if we have already seen this drawer
if found[drawer_key] == nil then
-- Create the new drawer and add it to the set of drawers
local side = Direction.directionSide(self.bot.dir, direction)
local drawer = Drawer:new(#self.drawers + 1, drawer_pos, direction, side)
table.insert(self.drawers, drawer)
-- Add the items from the drawer into our memory
self:addLocationsForDrawer(drawer)
-- Record that we have seen this drawer
found[drawer_key] = true
-- Report that we found a drawer
Log.info(("Discovered drawer at %s with %i slots:"):format(drawer.pos, #drawer.slots))
for index, item in pairs(drawer.slots) do
if item then
Log.info((" slot[%d] = %s"):format(index, item))
end
end
end
end
end
-- Perform a new scan of the blocks around us to find empty blocks
local scan = self:scanSurrounding()
-- Add the new scanned blocks if they're not present in the visited set
for _, block in ipairs(scan) do
if visited[AAUtils.positionKey(block)] == nil then
table.insert(stack, block)
end
end
end
-- Return to the home location
ok, err = self.bot:pathFind(self.home, 200)
if not ok then
Log.error("Failed to path find back to start:", err)
return false, "Failed to return to start"
end
ok, err = self.bot:face(Direction.North)
if not ok then
Log.error("Failed to face north:", err)
return false, "Failed to face north"
end
self:save()
return true
end
-----------------------------------------------------------------------------------------------
function StoreBot:fetchInput()
local ok, err = self.bot:face(Direction.West)
if not ok then
Log.error("Unable to turn to input chest:", err)
return nil, "Unable to turn to input chest"
end
local chest = peripheral.wrap("front")
if not chest then
Log.error("Failed to wrap peripheral for input chest")
return nil, "Failed to wrap peripheral for input chest"
end
-- local size = chest.size()
-- Log.info(("Input chest has %d slots"):format(size))
local received = 0
local slot = 1
while true do
turtle.select(slot)
ok, err = turtle.suck(64)
if not ok then
break
end
local info = turtle.getItemDetail()
Log.info(("Bot received %dx %s"):format(info.count, info.name))
received = received + info.count
slot = slot + 1
if slot > 16 then
Log.info("Turtle is full of items (input may have more)")
break
end
end
if received > 0 then
Log.info(("Bot has received %d items from input"):format(received))
end
ok, err = self.bot:face(Direction.North)
if not ok then
Log.error("Unable to turn back to home direction:", err)
return nil, "Unable to turn back to home direction"
end
return received
end
function StoreBot:putAway()
for slot = 1, 16 do
local info = turtle.getItemDetail(slot)
if info then
local loc = self.items[info.name]
if type(loc) == "number" then
local drawer = self.drawers[loc]
Log.info(("Storing %dx %s in drawer %s"):format(info.count, info.name, drawer.pos))
-- Move the bot over to the block infront of the drawer
local ok, err = self.bot:pathFind(drawer:getBotCoord())
if not ok then
Log.error("Failed to pathfind to storage drawer:", err)
return false, "Failed to pathfind to storage drawer"
end
-- Turn the bot to face the drawer so we can interact with it
ok, err = self.bot:face(drawer.dir)
if not ok then
Log.error("Failed to face storage drawer:", err)
return false, "Failed to face storage drawer"
end
-- Drop the items in this slot into the drawer
turtle.select(slot)
turtle.drop()
else
Log.info(("Unable to store %dx %s (unknown item)"):format(info.count, info.name))
end
end
end
end
function StoreBot:handleInput()
local count, ok, err
-- Turn to our input chest and see what's going on
count, err = self:fetchInput()
if count == nil then
Log.error("Failed to fetch input:", err)
return -1, "Failed to fetch input"
end
-- If we didn't receive anything, wait for a bit and then try again
if count == 0 then
return 0
end
-- Go through all our inventory and place the items
self:putAway()
-- Return to the home location
ok, err = self.bot:pathFind(self.home, 200)
if not ok then
Log.error("Failed to path find back to start:", err)
return -1, "Failed to return to start"
end
ok, err = self.bot:face(Direction.North)
if not ok then
Log.error("Failed to face north:", err)
return -1, "Failed to face north"
end
return count
end
function StoreBot:run()
local ok, err
while true do
-- Make sure that we have enough fuel
ok, err = self:refuelIfNeeded()
if not ok then
Log.error("Encountered error refueling:", err)
return false, "Failed to refuel bot"
end
-- See if we have any rednet items to process
if #self.digitized > 0 then
-- Take the digitized items from the bot
local digitized = self.digitized
self.digitized = {}
-- Rematerialize and then file the items
ok, err = self:handleMaterialized(digitized)
if not ok then
Log.error(("Failed to handle digitized items: %s"):format(err))
return false
end
-- If we have any remaining UUIDs, put them back into the bot
Table.concat(self.digitized, digitized)
end
-- See if we have anything to handle in our input
ok, err = self:handleInput()
if ok == -1 then
Log.error(("Failed to handle input: %s"):format(err))
return false
end
-- Wait for a bit if we didn't process anything
if ok == 0 then
sleep(10)
end
end
end
local function main(...)
local args = { ... }
local ok, err
if #args == 0 then
local bot = StoreBot:load()
ok, err = bot:run()
elseif #args == 1 then
if args[1] == "run" then
local bot = StoreBot:load()
ok, err = bot:run()
elseif args[1] == "scan" then
local bot = StoreBot:new()
ok, err = bot:scan()
elseif args[1] == "update" then
local bot = StoreBot:load()
ok, err = bot:update()
else
error("Unknown command: " .. args[1])
end
else
error("Usage: storage-bot.lua [run | scan]")
end
if not ok then
error("Bot failed to complete task: " .. (err or "<no error>"))
end
end
Log.trap(main, ...)