commit d533cad87000726a32bc4686d849e28b5a4cffb8 Author: yourfriendoss Date: Tue Nov 11 17:58:10 2025 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7721e4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +u2c.py +push.sh +version diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..296d35a --- /dev/null +++ b/.luarc.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", + "runtime.version": "Lua 5.2", + "format.enable": true, + "workspace.library": ["~/lua-ls-cc-tweaked/library"] +} diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..d7ce8ae --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,7 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "tab_size": 2 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..be003eb --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# (S)ophie (I)ncoroporated - (S)torage (S)olution +

SISS

+ +## How to use +1. First, in your /config.lua file, add the following: +```lua +return { + ["inventories"] = { -- REQUIRED!! Please set at least one inventory pattern. + ".*barrel.*" -- Lua patterns to match all barrels + }, + ["import"] = { -- Not required, just don't set! + "ender_storage_156" -- A inventory I want to move all items from into our storage + }, + ["chatbox"] = { -- Not required, just don't set! + ["hartbreix"] = "manipulator_42" -- Chatbox support + } +} +``` +2. Run the following command: + ``` + wget run https://git.sad.ovh/sophie/storage-solution/raw/branch/main/src/.main-installer.lua + ``` + + This will install storage-solution to /storage-solution. + To make it start every time you turn on the computer, add + + ``` + shell.run("wget run https://git.sad.ovh/sophie/storage-solution/raw/branch/main/src/.main-installer.lua") + ``` + to startup.lua + +### [EXTRA!] + +You can also run `wget run https://files.sad.ovh/public/storage-solution/.beta-installer.lua`, which is quite unstable. You may experience issues while running the beta version, but also new juicy features :) diff --git a/src/.beta-installer.lua b/src/.beta-installer.lua new file mode 100644 index 0000000..cfe7963 --- /dev/null +++ b/src/.beta-installer.lua @@ -0,0 +1,81 @@ +local base_url = "https://files.sad.ovh/public/storage-solution" +local api_url = base_url .. "?ls" +local download_root = "storage-solution" + +local function fetch_folder_list() + local response = http.get(api_url) + if not response then + error("Failed to get folder list from Copyparty API") + end + local body = response.readAll() + response.close() + local data = textutils.unserializeJSON(body) + return data +end + +local function download_file(path) + local file_url = base_url .. "/" .. path + local local_path = download_root .. "/" .. path + local response = http.get(file_url) + if response then + local file = fs.open(local_path, "wb") + if file then + file.write(response.readAll()) + file.close() + end + response.close() + else + print("Failed to download: " .. file_url) + end +end + +local function traverse_and_download(folder_data, prefix) + prefix = prefix or "" + + for _, file in ipairs(folder_data.files or {}) do + print("Downloading: " .. prefix .. file.href) + download_file(prefix .. file.href) + end + + for _, dir in ipairs(folder_data.dirs or {}) do + fs.makeDir(download_root .. "/" .. prefix .. dir.href) + local subdir_url = base_url .. "/" .. dir.href .. "?ls" + local response = http.get(subdir_url) + if response then + local body = response.readAll() + response.close() + local subdir_data = textutils.unserializeJSON(body) + traverse_and_download(subdir_data, prefix .. dir.href) + else + print("Failed to get subdirectory: " .. subdir_url) + end + end +end + +if fs.exists(download_root) then + if fs.exists(download_root .. "/version") then + local previousVersion = fs.open(download_root .. "/version", "r").readAll() + local currentVersion = http.get(base_url .. "/version").readAll(); + + if previousVersion == currentVersion then + print("Previous version " .. previousVersion .. " is already installed, we're on " .. currentVersion .. " aswell, so skipping installation.") + shell.run("storage-solution/main.lua") + return + else + print("Version " .. previousVersion .. " was already installed. Uninstalling.") + fs.delete(download_root) + end + else + print("Version marker does not exist. Cannot install.") + shell.run("storage-solution/main.lua") + + return + end +end +fs.makeDir(download_root) + +local folder_list = fetch_folder_list() +traverse_and_download(folder_list, "") + +print("Done :), installed Sophie's Storage Solution version " .. (http.get(base_url .. "/version").readAll())) +shell.run("storage-solution/main.lua") diff --git a/src/.main-installer.lua b/src/.main-installer.lua new file mode 100644 index 0000000..77096ba --- /dev/null +++ b/src/.main-installer.lua @@ -0,0 +1,101 @@ +-- Forgejo-based installer for Sophie's Storage Solution +local repo_api = "https://git.sad.ovh/api/v1/repos/sophie/storage-solution" +local raw_base = "https://git.sad.ovh/sophie/storage-solution/raw/branch/main" +local branch = "main" +local download_root = "storage-solution" + +-- Get latest commit hash for branch +local function fetch_commit_hash() + local url = repo_api .. "/branches/" .. branch + local response = http.get(url) + if not response then + error("Failed to fetch branch info: " .. url) + end + local body = response.readAll() + response.close() + local data = textutils.unserializeJSON(body) + return data.commit.id +end + +-- Fetch file/folder listing from Forgejo API +local function fetch_repo_tree(path) + local url = repo_api .. "/contents" .. (path and ("/" .. path) or "") + local response = http.get(url) + if not response then + error("Failed to fetch repo tree: " .. url) + end + local body = response.readAll() + response.close() + return textutils.unserializeJSON(body) +end + +-- Download a single file +local function download_file(path) + local url = raw_base .. "/" .. path + local local_path = download_root .. "/" .. path + fs.makeDir(fs.getDir(local_path)) + local response = http.get(url) + if response then + local file = fs.open(local_path, "wb") + if file then + file.write(response.readAll()) + file.close() + end + response.close() + print("Downloaded: " .. path) + else + print("Failed to download: " .. url) + end +end + +-- Recursively traverse Forgejo folders +local function traverse_and_download(path) + local tree = fetch_repo_tree(path) + for _, entry in ipairs(tree) do + if entry.type == "file" then + download_file(entry.path) + elseif entry.type == "dir" then + traverse_and_download(entry.path) + end + end +end + +-- Get current remote commit hash +local remote_hash = fetch_commit_hash() +print("Latest commit: " .. remote_hash) + +-- Check for existing version +if fs.exists(download_root) then + if fs.exists(download_root .. "/version") then + local f = fs.open(download_root .. "/version", "r") + local local_hash = f.readAll() + f.close() + + if local_hash == remote_hash then + print("Already up to date (commit " .. remote_hash:sub(1, 7) .. ").") + shell.run(download_root .. "/main.lua") + return + else + print("Outdated (" .. local_hash:sub(1, 7) .. " -> " .. remote_hash:sub(1, 7) .. "), reinstalling...") + fs.delete(download_root) + end + else + print("Version marker missing, reinstalling.") + fs.delete(download_root) + end +end + +-- Create root directory +fs.makeDir(download_root) + +-- Download everything +print("Fetching repository tree from Forgejo...") +traverse_and_download("") + +-- Save commit hash as version +local f = fs.open(download_root .. "/version", "w") +f.write(remote_hash) +f.close() + +print("Installed Sophie's Storage Solution (commit " .. remote_hash:sub(1, 7) .. ")") +shell.run(download_root .. "/main.lua") diff --git a/src/lib/abstractInventoryLib.lua b/src/lib/abstractInventoryLib.lua new file mode 100644 index 0000000..4dc868e --- /dev/null +++ b/src/lib/abstractInventoryLib.lua @@ -0,0 +1,1504 @@ +local abstractInventory +--- Inventory Abstraction Library +-- Inventory Peripheral API compatible library that caches the contents of chests, and allows for very fast transfers of items between AbstractInventory objects. +-- Transfers can occur from slot to slot, or by item name and nbt data. +-- This can also transfer to / from normal inventories, just pass in the peripheral name. +-- Use {optimal=false} to transfer to / from non-inventory peripherals. + +-- Now you can wrap arbritrary slot ranges +-- To do so, rather than passing in the inventory name when constructing (or adding/removing inventories) +-- you simply pass in a table of the following format +-- {name: string, minSlot: integer?, maxSlot: integer?, slots: integer[]?} +-- If slots is provided that overwrites anything in minSlot and maxSlot +-- minSlot defaults to 1, and maxSlot defaults to the inventory size + +-- Transfers with this inventory are parallel safe iff +-- * assumeLimits = true +-- * The limits of the abstractInventorys involved have already been cached +-- * refreshStorage() will do this +-- * The transfer is to an abstractInventory, or to an un-optimized peripheral +-- Though keep the 256 event queue limit in mind, as going over it will result in a stalled thread. + +-- Copyright 2022 Mason Gulu +-- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +-- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +-- Thank PG231 for the improved defrag! + +-- Updated 7/22/23 - Support for higher slot limit inventories + +-- Updated 4/12/24 - Added .run() and a built in transfer queue system + +-- Updated 4/14/24 - Added item allocation + +-- Updated 8/22/24 - Added .validateCache() + +local expect = require("cc.expect").expect + +local function ate(table, item) -- add to end + table[#table + 1] = item +end + +local function shallowClone(t) + local ct = {} + for k, v in pairs(t) do + ct[k] = v + end + return ct +end + +---Execute a table of functions in batches +---@param func function[] +---@param skipPartial? boolean Only do complete batches and skip the remainder. +---@param limit integer +---@return function[] skipped Functions that were skipped as they didn't fit. +local function batchExecute(func, skipPartial, limit) + local batches = #func / limit + batches = skipPartial and math.floor(batches) or math.ceil(batches) + for batch = 1, batches do + local start = ((batch - 1) * limit) + 1 + local batch_end = math.min(start + limit - 1, #func) + parallel.waitForAll(table.unpack(func, start, batch_end)) + end + return table.pack(table.unpack(func, 1 + limit * batches)) +end + +---Safely call an inventory "peripheral" +---@param name string|AbstractInventory|table +---@param func string +---@param ... unknown +---@return unknown +local function call(name, func, ...) + local args = table.pack(...) + if (func == "pullItems" or func == "pushItems") and type(args[1]) == "table" then + assert(type(name) == "string", "Cannot transfer items between two peripheral tables") + name, args[1] = args[1], name + if func == "pullItems" then + func = "pushItems" + else + func = "pullItems" + end + end + if type(name) == "string" then + return peripheral.call(name, func, table.unpack(args, 1, args.n)) + elseif type(name) == "table" then + return name[func](table.unpack(args, 1, args.n)) + end + error(("type(name)=%s"):format(type(name)), 2) +end + + + +---Perform an optimal transfer +---@param fromInventory AbstractInventory +---@param toInventory AbstractInventory +---@param from string|integer +---@param amount integer? +---@param toSlot integer? +---@param nbt string? +---@param options TransferOptions +---@param calln number? +---@param executeLimit integer +---@return unknown +local function optimalTransfer(fromInventory, toInventory, from, amount, toSlot, nbt, options, calln, executeLimit) + local theoreticalAmountMoved = 0 + local actualAmountMoved = 0 + local transferCache = {} + local badTransfer + while theoreticalAmountMoved < amount do + -- find the cachedItem item in fromInventory + ---@type CachedItem|nil + local cachedItem + if type(from) == "number" then + cachedItem = fromInventory._getGlobalSlot(from) + if not (cachedItem and cachedItem.item) or fromInventory._isSlotBusy(from) then + -- this slot is empty + break + end + else + cachedItem = fromInventory._getItem(from, nbt) + if not (cachedItem and cachedItem.item) then + -- no slots with this item + break + end + end + -- check how many items there are available to move + local itemsToMove = cachedItem.item.count + -- find where the item will be put + local destinationInfo + if toSlot then + destinationInfo = toInventory._getGlobalSlot(toSlot) + if not destinationInfo then + local info = toInventory._getLookupSlot(toSlot) + destinationInfo = toInventory._cacheItem(nil, info.inventory, info.slot) + end + else + destinationInfo = toInventory._getSlotWithSpace(cachedItem.item.name, nbt) + if not destinationInfo then + local slot, inventory, capacity = toInventory._getEmptySpace() + if not (slot and inventory) then + break + end + destinationInfo = toInventory._cacheItem(nil, inventory, slot) + end + end + + local slotCapacity = toInventory._getRealItemLimit(destinationInfo, + cachedItem.item.name, cachedItem.item.nbt) + if destinationInfo.item then + slotCapacity = slotCapacity - destinationInfo.item.count + end + itemsToMove = math.min(itemsToMove, slotCapacity, amount - theoreticalAmountMoved) + if destinationInfo.item and (destinationInfo.item.name ~= cachedItem.item.name) then + itemsToMove = 0 + end + if itemsToMove == 0 then + break + end + + -- queue a transfer of that item + local toInv, fromInv, fslot, limit, tslot = destinationInfo.inventory, cachedItem.inventory, cachedItem.slot, + itemsToMove, destinationInfo.slot + + if limit ~= 0 then + ate(transferCache, function() + local itemsMoved = call(toInv, "pullItems", fromInv, fslot, limit, tslot) + if options.itemMovedCallback then + options.itemMovedCallback() + end + actualAmountMoved = actualAmountMoved + itemsMoved + if not options.allowBadTransfers and itemsToMove ~= itemsMoved then + error(("Expected to move %d items, moved %d. (in call %s)"):format(itemsToMove, itemsMoved, calln)) + elseif not itemsToMove == itemsMoved then + badTransfer = true + end + end) + end + theoreticalAmountMoved = theoreticalAmountMoved + itemsToMove + + -- update destination cache to include the predicted transfer + if not destinationInfo.item then + destinationInfo.item = shallowClone(cachedItem.item) + destinationInfo.item.count = 0 + end + + destinationInfo.item.count = destinationInfo.item.count + itemsToMove + -- unique code + toInventory._cacheItem(destinationInfo.item, destinationInfo.inventory, destinationInfo.slot) + + -- update the other inventory's cache of that item to include the predicted transfer + local updatedItem = shallowClone(cachedItem.item) + updatedItem.count = updatedItem.count - itemsToMove + + if updatedItem.count == 0 then + fromInventory._cacheItem(nil, cachedItem.inventory, cachedItem.slot) + else + fromInventory._cacheItem(updatedItem, cachedItem.inventory, cachedItem.slot) + end + end + + batchExecute(transferCache, nil, executeLimit) + if badTransfer then + -- refresh inventories + toInventory.refreshStorage(options.autoDeepRefresh) + fromInventory.refreshStorage(options.autoDeepRefresh) + end + return actualAmountMoved +end + +---@class Item This is pulled directly from list(), or from getItemDetail(), so it may have more fields +---@field name string Name of this item +---@field nbt string|nil +---@field count integer +---@field maxCount integer? + +---@class TransferOptions +---@field optimal boolean|nil Try to optimize item movements, true default +---@field allowBadTransfers boolean|nil Recover from item transfers not going as planned (probably caused by someone tampering with the inventory) +---@field autoDeepRefresh boolean|nil Whether to do a deep refresh upon a bad transfer (requires bad transfers to be allowed) +---@field itemMovedCallback nil|fun(): nil Function called anytime an item is moved + +---@class CachedItem +---@field item Item|nil If an item is in this slot, this field will be an Item +---@field inventory string Inventory peripheral name +---@field slot integer Slot in inventory this CachedItem represents +---@field globalSlot integer Global slot of this CachedItem, spans across all wrapped inventories +---@field capacity integer + +---@class LogSettings +---@field filename string? +---@field cache boolean? +---@field optimal boolean? +---@field unoptimal boolean? +---@field api boolean? +---@field redirect fun(s:string)? +---@field defrag boolean? + +---@alias invPeripheral {list: function, pullItems: function, pushItems: function, getItemLimit: function, getItemDetail: function, size: function} + +---Wrap inventories and create an abstractInventory +---@param inventories table Table of inventory peripheral names to wrap +---@param assumeLimits boolean? Default true, assume the limit of each slot is the same, saves a TON of time +---@param logSettings LogSettings? +---@return AbstractInventory +function abstractInventory(inventories, assumeLimits, logSettings) + expect(1, inventories, "table") + expect(2, assumeLimits, "nil", "boolean") + ---@class AbstractInventory + local api = {} + api.abstractInventory = true + api.assumeLimits = assumeLimits + + local uid = tostring(api) + api.uid = uid + + if api.assumeLimits == nil then + api.assumeLimits = true + end + + local function optional(option, def) + if option == nil then + return def + end + return option + end + + ---@alias TaskID integer + + ---@class InventoryTask + ---@field type "pull"|"push" + ---@field id TaskID + ---@field args any[] + + ---Queue of inventory transfers + ---@type InventoryTask[] + local taskQueue = {} + + local maxExecuteLimit = 200 + local executeLimit = 200 + + local nextTaskId = 1 + + local maxSimiltaneousOperations = 8 + + local running = false + + local logCache = optional(logSettings and logSettings.cache, true) + local logOptimal = optional(logSettings and logSettings.optimal, true) + local logUnoptimal = optional(logSettings and logSettings.unoptimal, true) + local logApi = optional(logSettings and logSettings.api, true) + local logDefrag = optional(logSettings and logSettings.defrag, true) + + local logFilename = logSettings and logSettings.filename + if logFilename then + local logf = assert(fs.open(logFilename, "w")) + logf.close() + end + + local lastCallN = 0 + + local function log(formatString, ...) + if logSettings and logSettings.redirect then + logSettings.redirect(formatString:format(...)) + elseif logFilename then + local logf = assert(fs.open(logFilename, "a")) + logf.write(string.format(formatString, ...) .. "\n") + logf.close() + end + end + ---Log function entry + ---@param doLog boolean? + ---@param s string function name + ---@param ... any + ---@return number calln + local function logEntry(doLog, s, ...) + lastCallN = lastCallN + 1 + if doLog then + local args = table.pack(...) + local argFormat = string.rep("%s, ", args.n) + local formatString = string.format("[%u] -> %s(%s)", lastCallN, s, argFormat) + log(formatString, ...) + end + return lastCallN + end + ---Log function exit + ---@param doLog boolean? + ---@param calln number + ---@param s string function name + ---@param ... any return values + ---@return ... + local function logExit(doLog, calln, s, ...) + if doLog then + local retv = table.pack(...) + local retFormat = string.rep("%s, ", retv.n) + local formatString = string.format("[%u] %s(...) -> %s", calln, s, retFormat) + log(formatString, ...) + end + return ... + end + + ---@type table>> + local itemNameNBTLUT = {} + -- [item.name][nbt][CachedItem] -> CachedItem + + ---@type table>> + local itemSpaceLUT = {} + -- [item.name][nbt][CachedItem] -> CachedItem + + ---Keeps track of items that have at least 2 entries to itemSpaceLUT. + ---@type table> + local defraggableLUT = {} + -- [ite.name][nbt] -> number + + ---@type table> + local inventorySlotLUT = {} + -- [inventory][slot] = CachedItem + + ---@type table + local inventoryLimit = {} + -- [inventory] = number + + ---@type table> + local emptySlotLUT = {} + -- [inventory][slot] = true|nil + + ---@type table + local slotNumberLUT = {} + -- [global slot] -> {inventory:string, slot:number} + + ---@type table> + local inventorySlotNumberLUT = {} + -- [inventory][slot] -> global slot:number + + ---@type table> + local tagLUT = {} + -- [tag] -> string[] + + ---@type table> + local deepItemLUT = {} + -- [name][nbt] -> ItemInfo + + ---@alias ItemHandle {type:"handle"} + + ---@type table + local reservedItemLUT = {} + -- [handle] -> item reservation + + ---@type table + local busySlots = {} + + local function removeSlotFromEmptySlots(inventory, slot) + emptySlotLUT[inventory] = emptySlotLUT[inventory] or {} + emptySlotLUT[inventory][slot] = nil + if not next(emptySlotLUT[inventory]) then + emptySlotLUT[inventory] = nil + end + end + function api._isSlotBusy(slot) + return busySlots[slot] + end + + ---Cache a given item, ensuring that whatever was in the slot beforehand is wiped properly + ---And the caches are managed correctly. + ---@param item table|nil + ---@param inventory string|invPeripheral + ---@param slot number + ---@return CachedItem + local function cacheItem(item, inventory, slot) + local calln = logEntry(logCache, "cacheItem(%s, %s, %s)", + select(2, pcall(textutils.serialise, item, { compact = true })), + inventory, slot) + expect(1, item, "table", "nil") + expect(2, inventory, "string", "table") + expect(3, slot, "number") + local nbt = (item and item.nbt) or "NONE" + if item and item.name == "" then + item = nil + end + inventorySlotLUT[inventory] = inventorySlotLUT[inventory] or {} + if inventorySlotLUT[inventory][slot] then + local oldCache = inventorySlotLUT[inventory][slot] + local oldItem = oldCache.item + if oldItem and oldItem.name then + -- There was an item in this slot before, clean up the caches + local oldNBT = oldItem.nbt or "NONE" + if itemNameNBTLUT[oldItem.name] and itemNameNBTLUT[oldItem.name][oldNBT] then + itemNameNBTLUT[oldItem.name][oldNBT][oldCache] = nil + end + if itemSpaceLUT[oldItem.name] and itemSpaceLUT[oldItem.name][oldNBT] then + itemSpaceLUT[oldItem.name][oldNBT][oldCache] = nil + if defraggableLUT[oldItem.name] and defraggableLUT[oldItem.name][oldNBT] then + local newSpaces = defraggableLUT[oldItem.name][oldNBT] - 1 + if newSpaces >= 2 then + defraggableLUT[oldItem.name][oldNBT] = newSpaces + else + defraggableLUT[oldItem.name][oldNBT] = nil + if not next(defraggableLUT[oldItem.name]) then + defraggableLUT[oldItem.name] = nil + end + end + end + end + end + end + removeSlotFromEmptySlots(inventory, slot) + if not inventorySlotLUT[inventory][slot] then + inventorySlotLUT[inventory][slot] = { + item = item, + inventory = inventory, + slot = slot, + globalSlot = inventorySlotNumberLUT[inventory][slot] + } + end + if not inventorySlotLUT[inventory][slot].capacity then + if api.assumeLimits and inventoryLimit[inventory] then + inventorySlotLUT[inventory][slot].capacity = inventoryLimit[inventory] + else + inventorySlotLUT[inventory][slot].capacity = call(inventory, "getItemLimit", slot) + end + inventoryLimit[inventory] = inventorySlotLUT[inventory][slot].capacity + end + ---@type CachedItem + local cachedItem = inventorySlotLUT[inventory][slot] + cachedItem.item = item + if item and item.name and item.count > 0 then + itemNameNBTLUT[item.name] = itemNameNBTLUT[item.name] or {} + itemNameNBTLUT[item.name][nbt] = itemNameNBTLUT[item.name][nbt] or {} + itemNameNBTLUT[item.name][nbt][cachedItem] = cachedItem + if item.tags then + for k, v in pairs(item.tags) do + tagLUT[k] = tagLUT[k] or {} + tagLUT[k][item.name] = true + end + end + if emptySlotLUT[inventory] then + -- There's an item in this slot, therefor this slot is not empty + emptySlotLUT[inventory][slot] = nil + end + if item.count < item.maxCount then + -- There's space left in this slot, add it to the cache + itemSpaceLUT[item.name] = itemSpaceLUT[item.name] or {} + itemSpaceLUT[item.name][nbt] = itemSpaceLUT[item.name][nbt] or {} + defraggableLUT[item.name] = defraggableLUT[item.name] or {} + if next(itemSpaceLUT[item.name][nbt]) then + defraggableLUT[item.name][nbt] = (defraggableLUT[item.name][nbt] or 1) + 1 + end + itemSpaceLUT[item.name][nbt][cachedItem] = cachedItem + end + else + -- There is no item in this slot, this slot is empty + emptySlotLUT[inventory] = emptySlotLUT[inventory] or {} + emptySlotLUT[inventory][slot] = true + end + logExit(logCache, calln, "cacheItem", select(2, pcall(textutils.serialise, cachedItem, { compact = true }))) + return cachedItem + end + api._cacheItem = cacheItem + + ---Cache what's in a given slot + ---@param inventory string + ---@param slot number + ---@return CachedItem + local function cacheSlot(inventory, slot) + local calln = logEntry(logCache, "cacheSlot", inventory, slot) + return logExit(logCache, calln, "cacheSlot", cacheItem(call(inventory, "getItemDetail", slot), inventory, slot)) + end + + ---Refresh a CachedItem + ---@param item CachedItem + local function refreshItem(item) + cacheSlot(item.inventory, item.slot) + end + + local function refreshInventory(inventory, deep) + local deepCacheFunctions = {} + local inventoryName, slots, minSlot, maxSlot + if type(inventory) == "table" then + inventoryName = assert(inventory.name or (inventory.list and inventory), "Invalid inventory") + slots = inventory.slots + minSlot = inventory.minSlot or 1 + maxSlot = inventory.maxSlot or + assert(call(inventoryName, "size"), ("%s is not a valid inventory."):format(inventoryName)) + else + inventoryName = inventory + minSlot = 1 + maxSlot = assert(call(inventoryName, "size"), ("%s is not a valid inventory."):format(inventoryName)) + end + if not slots then + slots = {} + for i = minSlot, maxSlot do + slots[#slots + 1] = i + end + end + emptySlotLUT[inventoryName] = {} + for _, i in ipairs(slots) do + emptySlotLUT[inventoryName][i] = true + local slotnumber = #slotNumberLUT + 1 + slotNumberLUT[slotnumber] = { inventory = inventoryName, slot = i } + inventorySlotNumberLUT[inventoryName] = inventorySlotNumberLUT[inventoryName] or {} + inventorySlotNumberLUT[inventoryName][i] = slotnumber + end + inventoryLimit[inventoryName] = call(inventoryName, "getItemLimit", 1) -- this should make transfers from/to this inventory parallel safe. + local listings = call(inventoryName, "list") + if not deep then + for _, i in ipairs(slots) do + if listings[i] then + cacheItem(listings[i], inventoryName, i) + else + cacheItem(nil, inventoryName, i) + end + end + else + for _, i in ipairs(slots) do + local listing = listings[i] + if listing then + deepCacheFunctions[#deepCacheFunctions + 1] = function() + deepItemLUT[listing.name] = deepItemLUT[listing.name] or {} + if deepItemLUT[listing.name][listing.nbt or "NONE"] then + local item = shallowClone(deepItemLUT[listing.name][listing.nbt or "NONE"]) + item.count = listing.count + cacheItem(item, inventoryName, i) + else + local item = call(inventoryName, "getItemDetail", i) + cacheItem(item, inventoryName, i) + if item then + deepItemLUT[item.name][item.nbt or "NONE"] = item + end + end + end + else + cacheItem(nil, inventoryName, i) + end + end + end + return deepCacheFunctions + end + + local function doIndexesExist(t, ...) + for i, v in ipairs({ ... }) do + t = t[v] + if not t then + return false + end + end + return true + end + + ---Check if the internal caches are in a valid state + ---@return boolean + ---@return string + function api.validateCache() + -- Validate all cachedItems + for gslot, info in ipairs(slotNumberLUT) do + local inventory, slot = info.inventory, info.slot + local cachedItem = inventorySlotLUT[inventory][slot] + if not cachedItem then + return false, ("inventorySlotLUT[%s][%d] does not exist!"):format(inventory, slot) + end + local item = cachedItem.item + if item then + local name, nbt = item.name, item.nbt or "NONE" + if not doIndexesExist(itemNameNBTLUT, name, nbt, cachedItem) then + return false, ("itemNameNBTLUT[%s][%s] is missing an entry!"):format(name, nbt) + end + if inventorySlotNumberLUT[inventory][slot] ~= gslot then + return false, ("inventorySlotNumberLUT[%s][%d] is invalid!"):format(inventory, slot) + end + if item.count < 1 then + return false, ("Item with count %d exists!"):format(item.count) + elseif item.count > item.maxCount then + return false, ("Item with count higher than max exists! (%d / %d)"):format(item.count, item.maxCount) + end + if item.count < item.maxCount then + if not doIndexesExist(itemSpaceLUT, name, nbt, cachedItem) then + return false, ("itemSpaceLUT[%s][%s] is missing an entry!"):format(name, nbt) + end + else + if doIndexesExist(itemSpaceLUT, name, nbt, cachedItem) then + return false, ("itemSpaceLUT[%s][%s] contains an item it shouldn't!"):format(name, nbt) + end + end + if (doIndexesExist(emptySlotLUT, inventory, slot) and emptySlotLUT[inventory][slot]) then + return false, + ("emptySlotLut[%s][%d] is true when the slot isn't empty!"):format(inventory, slot) + end + else + if not (doIndexesExist(emptySlotLUT, inventory, slot) and emptySlotLUT[inventory][slot]) then + return false, + ("emptySlotLut[%s][%d] is false when the slot is empty!"):format(inventory, slot) + end + end + end + -- Validate that CachedItems aren't where they shouldn't be + for name, nbtList in pairs(itemNameNBTLUT) do + for nbt, cachedItemList in pairs(nbtList) do + for cachedItem in pairs(cachedItemList) do + local item = cachedItem.item + if not item then + return false, ("itemNameNBTLUT[%s][%s] contains empty CachedItem"):format(name, nbt) + end + if item.name ~= name then + return false, ("itemNameNBTLUT[%s][%s] contains item with name %s"):format(name, nbt, item.name) + end + if (item.nbt or "NONE") ~= nbt then + return false, ("itemNameNBTLUT[%s][%s] contains item with nbt %s"):format(name, nbt, item.nbt) + end + if inventorySlotLUT[cachedItem.inventory][cachedItem.slot] ~= cachedItem then + return false, ("itemNameNBTLUT[%s][%s] contains some imaginary CachedItem."):format(name, nbt) + end + end + end + end + for name, nbtList in pairs(itemSpaceLUT) do + for nbt, cachedItemList in pairs(nbtList) do + for cachedItem in pairs(cachedItemList) do + local item = cachedItem.item + if not item then + return false, ("itemSpaceLUT[%s][%s] contains empty CachedItem"):format(name, nbt) + end + if item.name ~= name then + return false, ("itemSpaceLUT[%s][%s] contains item with name %s"):format(name, nbt, item.name) + end + if (item.nbt or "NONE") ~= nbt then + return false, ("itemSpaceLUT[%s][%s] contains item with nbt %s"):format(name, nbt, item.nbt) + end + if item.count == item.maxCount then + return false, ("itemSpaceLUT[%s][%s] contains item with no extra space!"):format(name, nbt) + end + if inventorySlotLUT[cachedItem.inventory][cachedItem.slot] ~= cachedItem then + return false, ("itemSpaceLUT[%s][%s] contains some imaginary CachedItem."):format(name, nbt) + end + end + end + end + return true, "" + end + + ---Recache the inventory contents + ---@param deep nil|boolean call getItemDetail on every slot + function api.refreshStorage(deep) + if type(deep) == "nil" then + deep = true + end + itemNameNBTLUT, itemSpaceLUT, defraggableLUT, inventorySlotLUT, inventoryLimit, emptySlotLUT, slotNumberLUT, inventorySlotNumberLUT, tagLUT, deepItemLUT, reservedItemLUT = + {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {} + local inventoryRefreshers = {} + local deepCacheFunctions = {} + for _, inventory in pairs(inventories) do + table.insert(inventoryRefreshers, function() + for k, v in ipairs(refreshInventory(inventory, deep) or {}) do + deepCacheFunctions[#deepCacheFunctions + 1] = v + end + end) + end + batchExecute(inventoryRefreshers, nil, executeLimit) + batchExecute(deepCacheFunctions, nil, executeLimit) + end + + ---Get an inventory slot for a given item + ---@param name string + ---@param nbt nil|string + ---@return nil|CachedItem + local function getItem(name, nbt) + nbt = nbt or "NONE" + if not (itemNameNBTLUT[name] and itemNameNBTLUT[name][nbt]) then + return + end + ---@type CachedItem + local cached = next(itemNameNBTLUT[name][nbt]) + return cached + end + + ---@return string|nil inventory + ---@return integer|nil slot + local function getEmptySlot() + local inv = next(emptySlotLUT) + if not inv then + return + end + local slot = next(emptySlotLUT[inv]) + if not slot then + emptySlotLUT[inv] = nil + return getEmptySlot() + end + return inv, slot + end + + ---Get an inventory slot that has space for a given item + ---@param name string + ---@param nbt nil|string + ---@return nil|CachedItem + local function getSlotWithSpace(name, nbt) + nbt = nbt or "NONE" + if not (itemSpaceLUT[name] and itemSpaceLUT[name][nbt]) then + return + end + ---@type CachedItem + local cached = next(itemSpaceLUT[name][nbt]) + return cached + end + api._getSlotWithSpace = getSlotWithSpace + + ---@return integer|nil slot + ---@return string|nil inventory + ---@return integer capacity + local function getEmptySpace() + local inv, freeSlot = getEmptySlot() + local space + if inv and freeSlot and inventorySlotLUT[inv] and inventorySlotLUT[inv][freeSlot] then + space = inventorySlotLUT[inv][freeSlot].capacity + elseif inv and freeSlot then + cacheItem(nil, inv, freeSlot) + space = inventorySlotLUT[inv][freeSlot].capacity + else + space = 0 -- no slot found + end + return freeSlot, inv, space + end + + ---@param name string + ---@param nbt string|nil + ---@return CachedItem|nil + function api._getSlotFor(name, nbt) + return getSlotWithSpace(name, nbt) + end + + ---@return integer|nil slot + ---@return string|nil inventory + ---@return integer capacity + function api._getEmptySpace() + return getEmptySpace() + end + + ---@return CachedItem|nil + function api._getItem(name, nbt) + nbt = nbt or "NONE" + if not (itemNameNBTLUT[name] and itemNameNBTLUT[name][nbt]) then + return + end + return next(itemNameNBTLUT[name][nbt]) + end + + ---Get the number of items of this type you could store in this inventory + ---@param item CachedItem + ---@param name string + ---@param nbt string|nil + function api._getRealItemLimit(item, name, nbt) + local slotLimit = item.capacity + local stackSize = 64 + if item.item then + stackSize = item.item.maxCount + end + return (slotLimit / 64) * stackSize + end + + ---@param slot integer + ---@return CachedItem + local function getGlobalSlot(slot) + local slotInfo = slotNumberLUT[slot] + inventorySlotLUT[slotInfo.inventory] = inventorySlotLUT[slotInfo.inventory] or {} + if not inventorySlotLUT[slotInfo.inventory][slotInfo.slot] then + cacheSlot(slotInfo.inventory, slotInfo.slot) + end + return inventorySlotLUT[slotInfo.inventory][slotInfo.slot] + end + + ---@param slot integer + ---@return CachedItem|nil + function api._getGlobalSlot(slot) + return getGlobalSlot(slot) + end + + function api._getLookupSlot(slot) + return slotNumberLUT[slot] + end + + local defaultOptions = { + optimal = true, + allowBadTransfers = false, + autoDeepRefresh = false, + itemMovedCallback = nil, + } + + + + ---Perform a defrag on an individual item + ---@param name string + ---@param nbt string? + ---@param skipPartial boolean? + ---@param schedule function[]? + ---@return function[] leftovers transfers that need to be performed still + local function defragItem(name, nbt, skipPartial, schedule) + nbt = nbt or "NONE" + local callN = logEntry(logDefrag, "defragItem", name, nbt, skipPartial, schedule) + schedule = schedule or {} + ---@type {item: CachedItem, free: number, amt: number}[] + local pad = {} + itemSpaceLUT[name] = itemSpaceLUT[name] or {} + for item in pairs(itemSpaceLUT[name][nbt] or {}) do + pad[#pad + 1] = { + item = item, + free = item.item.maxCount - item.item.count, + amt = item.item.count, + } + end + local i, j = 1, #pad + while i < j do + local item = pad[j].item + local toItem = pad[i].item + local toMove = math.min(pad[i].free, pad[j].amt) + schedule[#schedule + 1] = function() + call(item.inventory, "pushItems", toItem.inventory, item.slot, toMove, toItem.slot) + refreshItem(item) + refreshItem(toItem) + end + pad[i].free = pad[i].free - toMove + pad[j].amt = pad[j].amt - toMove + if pad[i].free == 0 then i = i + 1 end + if pad[j].amt == 0 then j = j - 1 end + end + schedule = batchExecute(schedule, skipPartial, executeLimit) + return logExit(logDefrag, callN, "defragItem", schedule) + end + + local function pullItemsOptimal(fromInventory, fromSlot, amount, toSlot, nbt, options) + local calln = logEntry(logOptimal, "pullItemsOptimal", fromInventory, fromSlot, amount, toSlot, nbt) + if type(fromInventory) == "string" or not fromInventory.abstractInventory then + fromInventory = abstractInventory({ fromInventory }) + fromInventory.refreshStorage() + end + local ret = optimalTransfer(fromInventory, api, fromSlot, amount, toSlot, nbt, options, calln, executeLimit) + logExit(logOptimal, calln, "pullItemsOptimal", ret) + return ret + end + + local function pushItemsUnoptimal(targetInventory, name, amount, toSlot, nbt, options) + local calln = logEntry(logUnoptimal, "pushItemsUnoptimal", targetInventory, name, amount, toSlot, nbt) + -- This is to a normal inventory + local totalMoved = 0 + local rep = true + while totalMoved < amount and rep do + local item + if type(name) == "number" then + -- perform lookup + item = getGlobalSlot(name) + else + item = getItem(name, nbt) + end + if not (item and item.item) then + return logExit(logUnoptimal, calln, "pushItemsUnoptimal", totalMoved, "NO ITEM") + end + local citem = shallowClone(item.item) + local itemCount = citem.count + rep = (itemCount - totalMoved) < amount + local expectedMove = math.min(amount - totalMoved, 64) + local remainingItems = math.max(0, itemCount - expectedMove) + citem.count = remainingItems + if citem.count == 0 then + cacheItem(nil, item.inventory, item.slot) + else + cacheItem(citem, item.inventory, item.slot) + end + local amountMoved = call(item.inventory, "pushItems", targetInventory, item.slot, expectedMove, toSlot) + totalMoved = totalMoved + amountMoved + refreshItem(item) + if options.itemMovedCallback then + options.itemMovedCallback() + end + if amountMoved < expectedMove then + return logExit(logUnoptimal, calln, "pushItemsUnoptimal", totalMoved, "TARGET FULL") + end + end + return logExit(logUnoptimal, calln, "pushItemsUnoptimal", totalMoved) + end + + local function pushItemsOptimal(targetInventory, name, amount, toSlot, nbt, options) + local calln = logEntry(logOptimal, "pushItemsOptimal", targetInventory, name, amount, toSlot, nbt) + if type(targetInventory) == "string" or not targetInventory.abstractInventory then + -- We'll see if this is a good optimization or not + targetInventory = abstractInventory({ targetInventory }) + targetInventory.refreshStorage() + end + local ret = optimalTransfer(api, targetInventory, name, amount, toSlot, nbt, options, calln, executeLimit) + return logExit(logOptimal, calln, "pushItemsOptimal", ret) + end + + ---@param targetInventory string|AbstractInventory + ---@param name string|number|ItemHandle + ---@param amount nil|number + ---@param toSlot nil|number + ---@param nbt nil|string + ---@param options nil|TransferOptions + ---@return integer count + local function doPushItems(targetInventory, name, amount, toSlot, nbt, options) + local calln = logEntry(logApi, "doPushItems", targetInventory, name, amount, toSlot, nbt) + amount = amount or 64 + -- apply ItemHandle + local h + if type(name) == "table" and name.type == "handle" then + h = reservedItemLUT[name] + name = h.name + nbt = h.nbt + amount = math.min(amount, h.amount + api.getCount(name, nbt)) + elseif type(name) == "string" then + amount = math.min(amount, api.getCount(name, nbt)) + end + options = options or {} + for k, v in pairs(defaultOptions) do + if options[k] == nil then + options[k] = v + end + end + if type(targetInventory) == "string" then + local test = peripheral.wrap(targetInventory) + if not (test and test.size) then + options.optimal = false + end + end + local ret + if type(targetInventory) == "string" and not options.optimal then + ret = pushItemsUnoptimal(targetInventory, name, amount, toSlot, nbt, options) + else + ret = pushItemsOptimal(targetInventory, name, amount, toSlot, nbt, options) + end + if h then + h.amount = math.max(0, h.amount - ret) + end + return logExit(logApi, calln, "doPushItems", ret) + end + + ---Push items to an inventory + ---@param targetInventory string|AbstractInventory + ---@param name string|number|ItemHandle + ---@param amount nil|number + ---@param toSlot nil|number + ---@param nbt nil|string + ---@param options nil|TransferOptions + ---@return integer count + function api.pushItems(targetInventory, name, amount, toSlot, nbt, options) + expect(1, targetInventory, "string", "table") + expect(2, name, "string", "number", "table") + expect(3, amount, "nil", "number") + expect(4, toSlot, "nil", "number") + expect(5, nbt, "nil", "string") + expect(6, options, "nil", "table") + + if not running then + return doPushItems(targetInventory, name, amount, toSlot, nbt, options) + end + + return api.await(api.queuePush(targetInventory, name, amount, toSlot, nbt, options)) + end + + local function pullItemsUnoptimal(fromInventory, fromSlot, amount, toSlot, nbt, options) + local calln = logEntry(logUnoptimal, "pullItemsUnoptimal", fromInventory, fromSlot, amount, toSlot, nbt) + assert(type(fromSlot) == "number", "Must pull from a slot #") + local itemsPulled = 0 + while itemsPulled < amount do + local freeSlot, freeInventory, space + freeSlot, freeInventory, space = getEmptySpace() + if toSlot then + local toItem = getGlobalSlot(toSlot) + freeSlot, freeInventory, space = toItem.slot, toItem.inventory, toItem.capacity + end + if not (freeSlot and freeInventory) then + return logExit(logUnoptimal, calln, "pullItemsUnoptimal", itemsPulled, "OUT OF SPACE") + end + local limit = math.min(amount - itemsPulled, space) + busySlots[inventorySlotNumberLUT[freeInventory][freeSlot]] = true + cacheItem({ name = "UNKNOWN", count = 64, maxCount = 64 }, freeInventory, freeSlot) + local moved = call(freeInventory, "pullItems", fromInventory, fromSlot, limit, freeSlot) + local movedItem = cacheSlot(freeInventory, freeSlot) + busySlots[inventorySlotNumberLUT[freeInventory][freeSlot]] = nil + if options.itemMovedCallback then + options.itemMovedCallback() + end + itemsPulled = itemsPulled + moved + if moved > 0 and not toSlot then + defragItem(movedItem.item.name, movedItem.item.nbt) + end + if moved < limit then + -- there's no more items to pull + return logExit(logUnoptimal, calln, "pullItemsUnoptimal", itemsPulled, "OUT OF ITEMS") + end + end + return logExit(logUnoptimal, calln, "pullItemsUnoptimal", itemsPulled) + end + + local function doPullItems(fromInventory, fromSlot, amount, toSlot, nbt, options) + local calln = logEntry(logApi, "doPullItems", fromInventory, fromSlot, amount, toSlot, nbt) + options = options or {} + for k, v in pairs(defaultOptions) do + if options[k] == nil then + options[k] = v + end + end + amount = amount or 64 + nbt = nbt or "NONE" + if type(fromInventory) == "string" then + local test = peripheral.wrap(fromInventory) + if not (test and test.size) then + options.optimal = false + end + end + if options.optimal == nil then options.optimal = true end + local ret + if type(fromInventory) == "string" and not options.optimal then + ret = pullItemsUnoptimal(fromInventory, fromSlot, amount, toSlot, nbt, options) + else + ret = pullItemsOptimal(fromInventory, fromSlot, amount, toSlot, nbt, options) + end + return logExit(logApi, calln, "doPullItems", ret) + end + + ---Pull items from an inventory + ---@param fromInventory string|AbstractInventory + ---@param fromSlot string|number + ---@param amount nil|number + ---@param toSlot nil|number + ---@param nbt nil|string + ---@param options nil|TransferOptions + ---@return integer count + function api.pullItems(fromInventory, fromSlot, amount, toSlot, nbt, options) + expect(1, fromInventory, "table", "string") + expect(2, fromSlot, "number", "string") + expect(3, amount, "nil", "number") + expect(4, toSlot, "nil", "number") + expect(5, nbt, "nil", "string") + expect(6, options, "nil", "table") + + if not running then + return doPullItems(fromInventory, fromSlot, amount, toSlot, nbt, options) + end + return api.await(api.queuePull(fromInventory, fromSlot, amount, toSlot, nbt, options)) + end + + ---Queue a transfer + ---@param type "push"|"pull" + ---@param args any[] + ---@return TaskID + local function queue(type, args) + taskQueue[#taskQueue + 1] = { + type = type, + args = args, + id = nextTaskId + } + nextTaskId = nextTaskId + 1 + return nextTaskId - 1 + end + + ---Pull items from an inventory + ---@param fromInventory string|AbstractInventory + ---@param fromSlot string|number + ---@param amount nil|number + ---@param toSlot nil|number + ---@param nbt nil|string + ---@param options nil|TransferOptions + ---@return TaskID task + function api.queuePull(fromInventory, fromSlot, amount, toSlot, nbt, options) + expect(1, fromInventory, "table", "string") + expect(2, fromSlot, "number", "string") + expect(3, amount, "nil", "number") + expect(4, toSlot, "nil", "number") + expect(5, nbt, "nil", "string") + expect(6, options, "nil", "table") + + assert(running, "Call .run() to queue transfers!") + + return queue("pull", { fromInventory, fromSlot, amount, toSlot, nbt, options }) + end + + ---Push items to an inventory + ---@param targetInventory string|AbstractInventory + ---@param name string|number|ItemHandle + ---@param amount nil|number + ---@param toSlot nil|number + ---@param nbt nil|string + ---@param options nil|TransferOptions + ---@return integer count + function api.queuePush(targetInventory, name, amount, toSlot, nbt, options) + expect(1, targetInventory, "string", "table") + expect(2, name, "string", "number", "table") + expect(3, amount, "nil", "number") + expect(4, toSlot, "nil", "number") + expect(5, nbt, "nil", "string") + expect(6, options, "nil", "table") + + assert(running, "Call .run() to queue transfers!") + + return queue("push", { targetInventory, name, amount, toSlot, nbt, options }) + end + + ---@param task InventoryTask + local function processTask(task) + local result + if task.type == "pull" then + result = doPullItems(table.unpack(task.args)) + else + result = doPushItems(table.unpack(task.args)) + end + os.queueEvent("ail_task_complete", uid, task.id, result) + end + + local function waitToDoTasks() + local tid = os.startTimer(1) + while true do + local e, id = os.pullEvent() + if e == "timer" and id == tid then + return + elseif e == "ail_start_transfer" and id == uid then + os.cancelTimer(tid) + return + end + end + end + + ---Reserve an item for later use + ---@param amount integer + ---@param item string + ---@param nbt nil|string + ---@return ItemHandle? + function api.allocateItem(amount, item, nbt) + expect(1, item, "string") + expect(2, nbt, "nil", "string") + nbt = nbt or "NONE" + ---@type ItemHandle + local h = { type = "handle" } + + if api.getCount(item, nbt) < amount then + return + end + reservedItemLUT[h] = { + amount = amount, + name = item, + nbt = nbt, + handle = h + } + return h + end + + ---@param handle ItemHandle + function api.freeItem(handle) + reservedItemLUT[handle] = nil + end + + ---Check if a given handle is still valid. (Invalid when count = 0) + ---@param handle ItemHandle + ---@return boolean + function api.isHandleValid(handle) + return not not reservedItemLUT[handle] + end + + ---Call this to batch all AIL calls and execute multiple in parallel. + function api.run() + running = true + while true do + waitToDoTasks() + if #taskQueue > 0 then + local taskFuncs = {} + for i, v in ipairs(taskQueue) do + taskFuncs[i] = function() + processTask(v) + end + end + taskQueue = {} + + local batchSize = math.min(#taskFuncs, maxSimiltaneousOperations) + executeLimit = math.floor(maxExecuteLimit / batchSize) + batchExecute(taskFuncs, nil, batchSize) + + os.queueEvent("ail_transfer_complete", uid) + end + end + end + + ---Perform the transfer queue immediately + function api.performTransfer() + os.queueEvent("ail_start_transfer", uid) + end + + ---Wait for a task to complete + ---@param task TaskID + ---@return integer + function api.await(task) + while true do + local _, ailid, tid, result = os.pullEvent("ail_task_complete") + if ailid == uid and tid == task then + return result + end + end + end + + ---Get the amount of this item in storage + ---@param item string + ---@param nbt nil|string + ---@return integer + function api.getCount(item, nbt) + expect(1, item, "string") + expect(2, nbt, "nil", "string") + nbt = nbt or "NONE" + if not (itemNameNBTLUT[item] and itemNameNBTLUT[item][nbt]) then + return 0 + end + local totalCount = 0 + for k, v in pairs(itemNameNBTLUT[item][nbt]) do + totalCount = totalCount + v.item.count + end + for _, v in pairs(reservedItemLUT) do + if v.name == item and v.nbt == nbt then + totalCount = totalCount - v.amount + end + end + return totalCount + end + + ---Get a list of all items in this storage + ---@return CachedItem[] list + function api.listItems() + ---@type CachedItem[] + local t = {} + for name, nbtt in pairs(itemNameNBTLUT) do + for nbt, cachedItem in pairs(nbtt) do + ate(t, cachedItem) + end + end + return t + end + + ---Get a list of all item names in this storage + ---@return string[] + function api.listNames() + local t = {} + for k, v in pairs(itemNameNBTLUT) do + t[#t + 1] = k + end + return t + end + + ---Get the NBT hashes for a given item name + ---@param name string + ---@return string[] + function api.listNBT(name) + local t = {} + for k, v in pairs(itemNameNBTLUT[name] or {}) do + t[#t + 1] = k + end + return t + end + + ---Rearrange items to make the most efficient use of space + function api.defrag() + local schedule = {} + for name, nbts in pairs(defraggableLUT) do + for nbt in pairs(nbts) do + schedule = defragItem(name, nbt, true, schedule) + end + end + batchExecute(schedule, nil, executeLimit) + end + + ---Get a CachedItem by name/nbt + ---@param name string + ---@param nbt nil|string + ---@return CachedItem|nil + function api.getItem(name, nbt) + expect(1, name, "string") + expect(2, nbt, "nil", "string") + return getItem(name, nbt) -- this can be nil + end + + ---Get a CachedItem by slot + ---@param slot integer + ---@return CachedItem + function api.getSlot(slot) + expect(1, slot, "number") + return getGlobalSlot(slot) + end + + ---Change the max number of functions to run in parallel + ---@param n integer + function api.setBatchLimit(n) + expect(1, n, "number") + assert(n > 0, "Attempt to set negative/0 batch limit.") + if n < 10 then + error(string.format("Attempt to set batch limit too low. (%u)."):format(n)) + end + if n > 230 then + error( + string.format( + "Attempt to set batch limit to %u, the event queue is 256 elements long. This is very likely to result in dropped events.", + n), 2) + end + maxExecuteLimit = n + executeLimit = n + end + + ---Get an inventory peripheral compatible list of items in this storage + ---@return table + function api.list() + local t = {} + for itemName, nbtTable in pairs(itemNameNBTLUT) do + for nbt, cachedItems in pairs(nbtTable) do + for item, _ in pairs(cachedItems) do + t[inventorySlotNumberLUT[item.inventory][item.slot]] = item.item + end + end + end + return t + end + + ---Get a list of item name indexed counts of each item + ---@return table + function api.listItemAmounts() + local t = {} + for _, itemName in ipairs(api.listNames()) do + t[itemName] = 0 + for _, nbt in ipairs(api.listNBT(itemName)) do + t[itemName] = t[itemName] + api.getCount(itemName, nbt) + end + end + return t + end + + ---Get a list of items with the given tag + ---@param tag string + ---@return string[] + function api.getTag(tag) + local t = {} + for k, v in pairs(tagLUT[tag] or {}) do + table.insert(t, k) + end + return t + end + + ---Get the slot usage of this inventory + ---@return {free: integer, used:integer, total:integer} + function api.getUsage() + local ret = {} + ret.total = api.size() + ret.used = 0 + for i, _ in pairs(api.list()) do + ret.used = ret.used + 1 + end + ret.free = ret.total - ret.used + return ret + end + + ---Get the amount of slots in this inventory + ---@return integer + function api.size() + return #slotNumberLUT + end + + ---Get item information from a slot + ---@param slot integer + ---@return Item + function api.getItemDetail(slot) + expect(1, slot, "number") + local item = getGlobalSlot(slot) + if item.item == nil then + refreshItem(item) + end + return item.item + end + + ---Get maximum number of items that can be in a slot + ---@param slot integer + ---@return integer + function api.getItemLimit(slot) + expect(1, slot, "number") + local item = getGlobalSlot(slot) + return item.capacity + end + + ---pull all items from an inventory + ---@param inventory string|AbstractInventory + ---@return integer moved total items moved + function api.pullAll(inventory) + if type(inventory) == "string" or not inventory.abstractInventory then + inventory = abstractInventory({ inventory }) + inventory.refreshStorage() + end + local moved = 0 + for k, _ in pairs(inventory.list()) do + moved = moved + api.pullItems(inventory, k) + end + return moved + end + + local function getItemIndex(t, item) + for k, v in ipairs(t) do + if v == item then + return k + end + end + end + + ---Add an inventory to the storage object + ---@param inventory string|invPeripheral + ---@return boolean success + function api.addInventory(inventory) + expect(1, inventory, "string", "table") + if getItemIndex(inventories, inventory) then + return false + end + table.insert(inventories, inventory) + api.refreshStorage(true) + return true + end + + ---Remove an inventory from the storage object + ---@param inventory string|invPeripheral + ---@return boolean success + function api.removeInventory(inventory) + expect(1, inventory, "string", "table") + local index = getItemIndex(inventories, inventory) + if not index then + return false + end + table.remove(inventories, index) + api.refreshStorage(true) + return true + end + + ---Get the number of free slots in this inventory + ---@return integer + function api.freeSpace() + local count = 0 + for _, inventorySlots in pairs(emptySlotLUT) do + for _, _ in pairs(inventorySlots) do + count = count + 1 + end + end + return count + end + + ---Get the number of items of this type you could store in this inventory + ---@param name string + ---@param nbt string|nil + ---@return integer count + function api.totalSpaceForItem(name, nbt) + expect(1, name, "string") + expect(2, nbt, "string", "nil") + local count = 0 + for inventory, inventorySlots in pairs(emptySlotLUT) do + for slot in pairs(inventorySlots) do + count = count + api._getRealItemLimit(inventorySlotLUT[inventory][slot], name, nbt) + end + end + nbt = nbt or "NONE" + if itemSpaceLUT[name] and itemSpaceLUT[name][nbt] then + for _, cached in pairs(itemSpaceLUT[name][nbt]) do + count = count + (cached.capacity - cached.item.count) + end + end + return count + end + + api.refreshStorage(true) + + return api +end + +return abstractInventory diff --git a/src/lib/primeui.lua b/src/lib/primeui.lua new file mode 100644 index 0000000..c782978 --- /dev/null +++ b/src/lib/primeui.lua @@ -0,0 +1,466 @@ +-- PrimeUI by JackMacWindows +-- Public domain/CC0 + +local expect = require "cc.expect".expect + +-- Initialization code +local PrimeUI = {} +do + local coros = {} + local restoreCursor + + --- Adds a task to run in the main loop. + ---@param func function The function to run, usually an `os.pullEvent` loop + function PrimeUI.addTask(func) + expect(1, func, "function") + local t = {coro = coroutine.create(func)} + coros[#coros+1] = t + _, t.filter = coroutine.resume(t.coro) + end + + --- Sends the provided arguments to the run loop, where they will be returned. + ---@param ... any The parameters to send + function PrimeUI.resolve(...) + coroutine.yield(coros, ...) + end + + --- Clears the screen and resets all components. Do not use any previously + --- created components after calling this function. + function PrimeUI.clear() + -- Reset the screen. + term.setCursorPos(1, 1) + term.setCursorBlink(false) + term.setBackgroundColor(colors.black) + term.setTextColor(colors.white) + term.clear() + -- Reset the task list and cursor restore function. + coros = {} + restoreCursor = nil + end + + --- Sets or clears the window that holds where the cursor should be. + ---@param win window|nil The window to set as the active window + function PrimeUI.setCursorWindow(win) + expect(1, win, "table", "nil") + restoreCursor = win and win.restoreCursor + end + + --- Gets the absolute position of a coordinate relative to a window. + ---@param win window The window to check + ---@param x number The relative X position of the point + ---@param y number The relative Y position of the point + ---@return number x The absolute X position of the window + ---@return number y The absolute Y position of the window + function PrimeUI.getWindowPos(win, x, y) + if win == term then return x, y end + while win ~= term.native() and win ~= term.current() do + if not win.getPosition then return x, y end + local wx, wy = win.getPosition() + x, y = x + wx - 1, y + wy - 1 + _, win = debug.getupvalue(select(2, debug.getupvalue(win.isColor, 1)), 1) -- gets the parent window through an upvalue + end + return x, y + end + + --- Runs the main loop, returning information on an action. + ---@return any ... The result of the coroutine that exited + function PrimeUI.run() + while true do + -- Restore the cursor and wait for the next event. + if restoreCursor then restoreCursor() end + local ev = table.pack(os.pullEvent()) + -- Run all coroutines. + for _, v in ipairs(coros) do + if v.filter == nil or v.filter == ev[1] then + -- Resume the coroutine, passing the current event. + local res = table.pack(coroutine.resume(v.coro, table.unpack(ev, 1, ev.n))) + -- If the call failed, bail out. Coroutines should never exit. + if not res[1] then error(res[2], 2) end + -- If the coroutine resolved, return its values. + if res[2] == coros then return table.unpack(res, 3, res.n) end + -- Set the next event filter. + v.filter = res[2] + end + end + end + end +end + +--- Creates a text input box. +---@param win window The window to draw on +---@param x number The X position of the left side of the box +---@param y number The Y position of the box +---@param width number The width/length of the box +---@param action function|string A function or `run` event to call when a key is pressed +---@param fgColor color|nil The color of the text (defaults to white) +---@param bgColor color|nil The color of the background (defaults to black) +---@param replacement string|nil A character to replace typed characters with +---@param history string[]|nil A list of previous entries to provide +---@param completion function|nil A function to call to provide completion +function PrimeUI.inputBox(win, x, y, width, action, fgColor, bgColor, replacement, history, completion, default) + expect(1, win, "table") + expect(2, x, "number") + expect(3, y, "number") + expect(4, width, "number") + expect(5, action, "function", "string") + fgColor = expect(6, fgColor, "number", "nil") or colors.white + bgColor = expect(7, bgColor, "number", "nil") or colors.black + expect(8, replacement, "string", "nil") + expect(9, history, "table", "nil") + expect(10, completion, "function", "nil") + expect(11, default, "string", "nil") + + local box = window.create(win, x, y, width, 1) + box.setTextColor(fgColor) + box.setBackgroundColor(bgColor) + box.clear() + + PrimeUI.addTask(function() + local text = default or "" + local cursor = #text + 1 + local histIndex = nil + + local function runAction() + if type(action) == "string" then + PrimeUI.resolve("inputBox", action, text) + else + action(text) + end + end + + + local function redraw() + box.clear() + box.setCursorPos(1, 1) + if replacement then + box.write(string.rep(replacement, #text)) + else + box.write(text) + end + box.setCursorPos(cursor, 1) + + runAction() + end + + redraw() + + while true do + local ev, p1 = os.pullEvent() + if ev == "char" then + text = text:sub(1, cursor - 1) .. p1 .. text:sub(cursor) + cursor = cursor + 1 + redraw() + elseif ev == "key" then + if p1 == keys.enter then + runAction() + elseif p1 == keys.left then + cursor = math.max(1, cursor - 1) + box.setCursorPos(cursor, 1) + elseif p1 == keys.right then + cursor = math.min(#text + 1, cursor + 1) + box.setCursorPos(cursor, 1) + elseif p1 == keys.backspace then + if cursor > 1 then + text = text:sub(1, cursor - 2) .. text:sub(cursor) + cursor = cursor - 1 + redraw() + end + elseif p1 == keys.delete then + text = text:sub(1, cursor - 1) .. text:sub(cursor + 1) + redraw() + elseif p1 == keys.up and history then + if not histIndex then histIndex = #history + 1 end + histIndex = math.max(1, histIndex - 1) + text = history[histIndex] or "" + cursor = #text + 1 + redraw() + elseif p1 == keys.down and history then + if histIndex then + histIndex = math.min(#history + 1, histIndex + 1) + text = history[histIndex] or "" + cursor = #text + 1 + redraw() + end + end + end + end + end) +end + +--- Draws a line of text at a position. +---@param win window The window to draw on +---@param x number The X position of the left side of the text +---@param y number The Y position of the text +---@param text string The text to draw +---@param fgColor color|nil The color of the text (defaults to white) +---@param bgColor color|nil The color of the background (defaults to black) +function PrimeUI.label(win, x, y, text, fgColor, bgColor) + expect(1, win, "table") + expect(2, x, "number") + expect(3, y, "number") + expect(4, text, "string") + fgColor = expect(5, fgColor, "number", "nil") or colors.white + bgColor = expect(6, bgColor, "number", "nil") or colors.black + win.setCursorPos(x, y) + win.setTextColor(fgColor) + win.setBackgroundColor(bgColor) + win.write(text) +end + +--- Creates a scrollable window, which allows drawing large content in a small area. +---@param win window The parent window of the scroll box +---@param x number The X position of the box +---@param y number The Y position of the box +---@param width number The width of the box +---@param height number The height of the outer box +---@param innerHeight number The height of the inner scroll area +---@param allowArrowKeys boolean|nil Whether to allow arrow keys to scroll the box (defaults to true) +---@param showScrollIndicators boolean|nil Whether to show arrow indicators on the right side when scrolling is available, which reduces the inner width by 1 (defaults to false) +---@param fgColor number|nil The color of scroll indicators (defaults to white) +---@param bgColor color|nil The color of the background (defaults to black) +---@return window inner The inner window to draw inside +---@return fun(pos:number) scroll A function to manually set the scroll position of the window +function PrimeUI.scrollBox(win, x, y, width, height, innerHeight, allowArrowKeys, showScrollIndicators, fgColor, bgColor) + expect(1, win, "table") + expect(2, x, "number") + expect(3, y, "number") + expect(4, width, "number") + expect(5, height, "number") + expect(6, innerHeight, "number") + expect(7, allowArrowKeys, "boolean", "nil") + expect(8, showScrollIndicators, "boolean", "nil") + fgColor = expect(9, fgColor, "number", "nil") or colors.white + bgColor = expect(10, bgColor, "number", "nil") or colors.black + if allowArrowKeys == nil then allowArrowKeys = true end + -- Create the outer container box. + local outer = window.create(win == term and term.current() or win, x, y, width, height) + outer.setBackgroundColor(bgColor) + outer.clear() + -- Create the inner scrolling box. + local inner = window.create(outer, 1, 1, width - (showScrollIndicators and 1 or 0), innerHeight) + inner.setBackgroundColor(bgColor) + inner.clear() + -- Draw scroll indicators if desired. + if showScrollIndicators then + outer.setBackgroundColor(bgColor) + outer.setTextColor(fgColor) + outer.setCursorPos(width, height) + outer.write(innerHeight > height and "\31" or " ") + end + -- Get the absolute position of the window. + x, y = PrimeUI.getWindowPos(win, x, y) + -- Add the scroll handler. + local scrollPos = 1 + PrimeUI.addTask(function() + while true do + -- Wait for next event. + local ev = table.pack(os.pullEvent()) + -- Update inner height in case it changed. + innerHeight = select(2, inner.getSize()) + -- Check for scroll events and set direction. + local dir + if ev[1] == "key" and allowArrowKeys then + if ev[2] == keys.up then dir = -1 + elseif ev[2] == keys.down then dir = 1 end + elseif ev[1] == "mouse_scroll" and ev[3] >= x and ev[3] < x + width and ev[4] >= y and ev[4] < y + height then + dir = ev[2] + end + -- If there's a scroll event, move the window vertically. + if dir and (scrollPos + dir >= 1 and scrollPos + dir <= innerHeight - height) then + scrollPos = scrollPos + dir + inner.reposition(1, 2 - scrollPos) + end + -- Redraw scroll indicators if desired. + if showScrollIndicators then + outer.setBackgroundColor(bgColor) + outer.setTextColor(fgColor) + outer.setCursorPos(width, 1) + outer.write(scrollPos > 1 and "\30" or " ") + outer.setCursorPos(width, height) + outer.write(scrollPos < innerHeight - height and "\31" or " ") + end + end + end) + -- Make a function to allow external scrolling. + local function scroll(pos) + expect(1, pos, "number") + pos = math.floor(pos) + expect.range(pos, 1, innerHeight - height) + -- Scroll the window. + scrollPos = pos + inner.reposition(1, 2 - scrollPos) + -- Redraw scroll indicators if desired. + if showScrollIndicators then + outer.setBackgroundColor(bgColor) + outer.setTextColor(fgColor) + outer.setCursorPos(width, 1) + outer.write(scrollPos > 1 and "\30" or " ") + outer.setCursorPos(width, height) + outer.write(scrollPos < innerHeight - height and "\31" or " ") + end + end + return inner, scroll +end + + +--- Creates a list of entries that can each be selected. +---@param win window The window to draw on +---@param x number The X coordinate of the inside of the box +---@param y number The Y coordinate of the inside of the box +---@param width number The width of the inner box +---@param height number The height of the inner box +---@param entries function A function that returns a list of entries to show, where the value is whether the item is pre-selected (or `"R"` for required/forced selected) +---@param action function|string A function or `run` event that's called when a selection is made +---@param selectChangeAction function|string|nil A function or `run` event that's called when the current selection is changed +---@param fgColor color|nil The color of the text (defaults to white) +---@param bgColor color|nil The color of the background (defaults to black) +function PrimeUI.selectionBox(win, x, y, width, height, entries, action, selectChangeAction, fgColor, bgColor) + expect(1, win, "table") + expect(2, x, "number") + expect(3, y, "number") + expect(4, width, "number") + expect(5, height, "number") + expect(6, entries, "function") + expect(7, action, "function", "string") + expect(8, selectChangeAction, "function", "string", "nil") + fgColor = expect(9, fgColor, "number", "nil") or colors.white + bgColor = expect(10, bgColor, "number", "nil") or colors.black + + local entrywin = window.create(win, x, y, width, height) + local selection, scroll = 1, 1 + -- Create a function to redraw the entries on screen. + local function drawEntries() + -- Clear and set invisible for performance. + entrywin.setVisible(false) + entrywin.setBackgroundColor(bgColor) + entrywin.clear() + -- Draw each entry in the scrolled region. + for i = scroll, scroll + height - 1 do + -- Get the entry; stop if there's no more. + local e = entries()[i] + if not e then break end + -- Set the colors: invert if selected. + entrywin.setCursorPos(2, i - scroll + 1) + if i == selection then + entrywin.setBackgroundColor(fgColor) + entrywin.setTextColor(bgColor) + else + entrywin.setBackgroundColor(bgColor) + entrywin.setTextColor(fgColor) + end + -- Draw the selection. + entrywin.clearLine() + entrywin.write(#e > width - 1 and e:sub(1, width - 4) .. "..." or e) + end + -- Draw scroll arrows. + entrywin.setBackgroundColor(bgColor) + entrywin.setTextColor(fgColor) + entrywin.setCursorPos(width, 1) + entrywin.write("\30") + entrywin.setCursorPos(width, height) + entrywin.write("\31") + -- Send updates to the screen. + entrywin.setVisible(true) + end + -- Draw first screen. + drawEntries() + -- Add a task for selection keys. + PrimeUI.addTask(function() + while true do + local event, key, cx, cy = os.pullEvent() + if event == "key" then + if key == keys.down and selection < #entries() then + -- Move selection down. + selection = selection + 1 + if selection > scroll + height - 1 then scroll = scroll + 1 end + -- Send action if necessary. + if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection) + elseif selectChangeAction then selectChangeAction(selection) end + -- Redraw screen. + drawEntries() + elseif key == keys.up and selection > 1 then + -- Move selection up. + selection = selection - 1 + if selection < scroll then scroll = scroll - 1 end + -- Send action if necessary. + if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection) + elseif selectChangeAction then selectChangeAction(selection) end + -- Redraw screen. + drawEntries() + elseif key == keys.enter then + -- Select the entry: send the action. + if type(action) == "string" then PrimeUI.resolve("selectionBox", action, entries()[selection]) + else action(entries()[selection]) end + end + elseif event == "mouse_click" and key == 1 then + -- Handle clicking the scroll arrows. + local wx, wy = PrimeUI.getWindowPos(entrywin, 1, 1) + if cx == wx + width - 1 then + if cy == wy and selection > 1 then + -- Move selection up. + selection = selection - 1 + if selection < scroll then scroll = scroll - 1 end + -- Send action if necessary. + if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection) + elseif selectChangeAction then selectChangeAction(selection) end + -- Redraw screen. + drawEntries() + elseif cy == wy + height - 1 and selection < #entries() then + -- Move selection down. + selection = selection + 1 + if selection > scroll + height - 1 then scroll = scroll + 1 end + -- Send action if necessary. + if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection) + elseif selectChangeAction then selectChangeAction(selection) end + -- Redraw screen. + drawEntries() + end + elseif cx >= wx and cx < wx + width - 1 and cy >= wy and cy < wy + height then + local sel = scroll + (cy - wy) + if sel == selection then + -- Select the entry: send the action. + if type(action) == "string" then PrimeUI.resolve("selectionBox", action, entries()[selection]) + else action(entries()[selection]) end + else + selection = sel + -- Send action if necessary. + if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection) + elseif selectChangeAction then selectChangeAction(selection) end + -- Redraw screen. + drawEntries() + end + end + elseif event == "mouse_scroll" then + -- Handle mouse scrolling. + local wx, wy = PrimeUI.getWindowPos(entrywin, 1, 1) + if cx >= wx and cx < wx + width and cy >= wy and cy < wy + height then + if key < 0 and selection > 1 then + -- Move selection up. + selection = selection - 1 + if selection < scroll then scroll = scroll - 1 end + -- Send action if necessary. + if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection) + elseif selectChangeAction then selectChangeAction(selection) end + -- Redraw screen. + drawEntries() + elseif key > 0 and selection < #entries() then + -- Move selection down. + selection = selection + 1 + if selection > scroll + height - 1 then scroll = scroll + 1 end + -- Send action if necessary. + if type(selectChangeAction) == "string" then PrimeUI.resolve("selectionBox", selectChangeAction, selection) + elseif selectChangeAction then selectChangeAction(selection) end + -- Redraw screen. + drawEntries() + end + end + end + end + end) + return drawEntries +end + + +return { + PrimeUI = PrimeUI +} diff --git a/src/main.lua b/src/main.lua new file mode 100644 index 0000000..c15dc9d --- /dev/null +++ b/src/main.lua @@ -0,0 +1,42 @@ +---@class Config +---@field inventories string[] +---@field import string[]|nil +---@field chatbox table|nil + +local inv = require("modules.inv") +local ui = require("modules.ui") +local chatbox = require("modules.chatbox") + +local config = require("../config") ---@type Config + +local function importMechanism() + if config.import == nil then + return + end + if #config.import == 0 then + return + end + + while true do + for _, import_from_inv in ipairs(config.import) do + ---@type ccTweaked.peripheral.Inventory + local perip = peripheral.wrap(import_from_inv) + if perip and perip.size then + local slotsToSend = {} + for slot = 1, perip.size() do + local item = perip.getItemDetail(slot) + if item then + table.insert(slotsToSend, slot) + end + end + if #slotsToSend > 0 then + inv.sendItemAwayMultiple(slotsToSend, perip, import_from_inv) + end + end + end + sleep(0.1) + end +end + +inv.sync() +parallel.waitForAll(chatbox.run, inv.getAIL().run, importMechanism, ui.runUi, inv.detectPlayerInsert) diff --git a/src/modules/chatbox.lua b/src/modules/chatbox.lua new file mode 100644 index 0000000..c90ed48 --- /dev/null +++ b/src/modules/chatbox.lua @@ -0,0 +1,212 @@ +local config = require("../../config") ---@type Config +local inv = require("modules.inv") + +local function levDist(s, t) + local n = #s + local m = #t + + if n == 0 then return m end + if m == 0 then return n end + + -- create matrix + local d = {} + for i = 0, n do + d[i] = {} + end + + -- initialize + for i = 0, n do d[i][0] = i end + for j = 0, m do d[0][j] = j end + + -- main loop + for i = 1, n do + local s_i = s:sub(i, i) + + for j = 1, m do + -- safeguard shortcut + if i == j and d[i][j] and d[i][j] > 4 then + return n + end + + local t_j = t:sub(j, j) + local cost = (s_i == t_j) and 0 or 1 + + local mi = math.min( + d[i - 1][j] + 1, + d[i][j - 1] + 1, + d[i - 1][j - 1] + cost + ) + + d[i][j] = mi + + -- Damerau transposition + if i > 1 and j > 1 and s_i == t:sub(j - 1, j - 1) and s:sub(i - 1, i - 1) == t_j then + d[i][j] = math.min(d[i][j], d[i - 2][j - 2] + cost) + end + end + end + + return d[n][m] +end + +local function findBest(data, item) + + local sorted_data = {} + + for _, z in ipairs(data) do + local parts = {} + for part in string.gmatch(z, "([^:]+)") do + table.insert(parts, part) + end + + local key = parts[1] .. ":" .. (parts[2] or "") + local dist = levDist(item, parts[2] or "") + table.insert(sorted_data, { key, dist }) + end + + table.sort(sorted_data, function(a, b) + return a[2] < b[2] + end) + + local best = {} + local best_dist = sorted_data[1][2] + for _, z in ipairs(sorted_data) do + if z[2] == best_dist then + table.insert(best, z) + else + break + end + end + + return best +end + +local BOT_NAME = "&cS &eI&an&3c &5S&cI&6S" + +function auth(user) + local manip = config.chatbox[user] + if manip then + return true + else + chatbox.tell(user, "You are not authorized to use this command.", BOT_NAME) + return false + end +end + +function run() + if config.chatbox == nil then return end + if #config.chatbox == 0 then return end + + while true do + local _, user, command, args = os.pullEvent("command") + + if command == "sis" then + if args[1] == "whoami" then + local manip = config.chatbox[user] + if manip then + chatbox.tell(user, "You are " .. user .. ", linked with `" .. manip .."`.", BOT_NAME) + else + chatbox.tell(user, "You are not registered. Ask the creator of this SIS to be registered.", BOT_NAME) + end + elseif args[1] == "deposit" then + if not auth(user) then goto continue end + + if not args[2] then + chatbox.tell(user, "Supply a item (minecraft:`diamond` part) to deposit it!", BOT_NAME) + goto continue + end + + + local perip = peripheral.wrap(config.chatbox[user]) + + ---@type ccTweaked.peripheral.Inventory + local peripInventory = perip.getInventory() + local invList = peripInventory.list() + + local allItems = {} + local seen = {} + + for _, stack in pairs(invList) do + local name = stack and stack.name + if name and not seen[name] then + seen[name] = true + allItems[#allItems + 1] = name + end + end + + local best = findBest(allItems, args[2]) + + local itemName = "" + + if #best > 1 then + itemName = best[1][1] + if string.find(":", args[2]) then + itemName = args[2] + end + chatbox.tell(user, "`WARNING`: Item most likely inaccurate. Assuming " .. itemName, BOT_NAME) + else + itemName = best[1][1] + end + + local slots = {} + + for slot, item in pairs(peripInventory.list()) do + if item.name == itemName then + table.insert(slots, slot) + end + end + + local amount = nil + + if args[3] then + amount = tonumber(args[3], 10) + end + + local moved = inv.sendItemAwayMultiple(slots, peripInventory, peripInventory, amount) + + chatbox.tell(user, "Moved `" .. tostring(moved) .. "` items.", BOT_NAME) + elseif args[1] == "withdraw" then + if not auth(user) then goto continue end + + if not args[2] then + chatbox.tell(user, "Supply a item (minecraft:`diamond` part) to withdraw it!", BOT_NAME) + goto continue + end + + local perip = peripheral.wrap(config.chatbox[user]) + + ---@type ccTweaked.peripheral.Inventory + local peripInventory = perip.getInventory() + + local best = findBest(inv.getAIL().listNames(), args[2]) + + local itemName = "" + + if #best > 1 then + itemName = best[1][1] + if string.find(":", args[2]) then + itemName = args[2] + end + chatbox.tell(user, "`WARNING`: Item most likely inaccurate. Assuming " .. itemName, BOT_NAME) + else + itemName = best[1][1] + end + + local amount = nil + + if args[3] then + amount = tonumber(args[3], 10) + end + + local moved = inv.sendItemToSelf(itemName, peripInventory, amount) + + chatbox.tell(user, "Moved `" .. tostring(moved) .. "` items.", BOT_NAME) + end + end + ::continue:: + end +end + +return { + run = run +} diff --git a/src/modules/inv.lua b/src/modules/inv.lua new file mode 100644 index 0000000..3e8a3a8 --- /dev/null +++ b/src/modules/inv.lua @@ -0,0 +1,199 @@ +local previousInventory = {} +---@type ccTweaked.peripheral.WiredModem +local modem = peripheral.find("modem"); +local turtleId = modem.getNameLocal() +local turtleMoveAllowed = true +local config = require("../../config") ---@type Config + +local abstractInventoryLib = require("lib.abstractInventoryLib") + +local peripherals = peripheral.getNames(); + +---@type AbstractInventory +local ail = nil + +local function sync() + print("Syncing..") + ---@type string[] + local foundInventories = {} + + for _, invPattern in ipairs(config.inventories) do + for _, inv in ipairs(peripherals) do + if string.find(inv, invPattern) then + table.insert(foundInventories, inv) + end + end + end + + ail = abstractInventoryLib(foundInventories, true) + + print("Synced."); +end +---@param itemName string +---@param perip ccTweaked.peripheral.Inventory|nil +---@param maxAmount number|nil +local function sendItemToSelf(itemName, perip, maxAmount) + if perip == nil then + perip = turtleId + end + turtleMoveAllowed = false + + local remaining = maxAmount or 64 + local total = 0 + + if remaining <= 0 then + turtleMoveAllowed = true + return + end + + if ail.getItem(itemName) then + local nbtList = ail.listNBT(itemName) + local chosenNBT = nil + if nbtList and #nbtList > 0 then + chosenNBT = nbtList[math.random(1, #nbtList)] + end + + if chosenNBT == "NONE" then + chosenNBT = nil + end + + while remaining > 0 do + local toSend = math.min(64, remaining) + + local ok, moved = pcall(function() + ail.performTransfer() + --chatbox.tell("hartbreix", tostring(perip) .. " <> " .. tostring(itemName) .. " <> " .. tostring(toSend) .. " <> " .. tostring(chosenNBT), "debug") + local amount = ail.pushItems(perip, itemName, toSend, nil, chosenNBT, { + ["allowBadTransfers"] = true, + ["optimal"] = true + }) + + ail.performTransfer() + + return amount + end) + total = total + moved + if ok and moved and moved > 0 then + remaining = remaining - moved + else + break + end + end + end + -- sleep(0.1) + turtleMoveAllowed = true + + return total +end + +---@param slots ccTweaked.turtle.turtleSlot[] +---@param perip ccTweaked.peripheral.Inventory|nil +---@param id string|nil +---@param maxAmount number|nil +local function sendItemAwayMultiple(slots, perip, id, maxAmount) + if perip == nil then + perip = turtle + end + local srcId = id or turtleId + + local itemsInSlots = {} + for _, slot in ipairs(slots) do + local item = perip.getItemDetail(slot) + if item then + itemsInSlots[slot] = item + end + end + + local totalMoved = 0 + local remaining = maxAmount or math.huge + + if remaining <= 0 then + return 0 + end + + for _, slot in ipairs(slots) do + if remaining <= 0 then break end + + local toSend = math.min(64, remaining) + + local ok, pulled = pcall(function() + ail.performTransfer() + + local moved = ail.pullItems(srcId, slot, toSend, nil, nil, { + ["allowBadTransfers"] = true, + ["optimal"] = false + }) + + ail.performTransfer() + return moved + end) + + if ok and pulled and pulled > 0 then + totalMoved = totalMoved + pulled + remaining = remaining - pulled + end + end + + local numItems = 0 + for _, _ in pairs(itemsInSlots) do + numItems = numItems + 1 + end + + if numItems > 0 then + os.queueEvent("update_ui") + end + + return totalMoved +end + +local function getTurtleInventory() + local inventory = {} + for slot = 1, 16 do + local item = turtle.getItemDetail(slot, false) + inventory[slot] = item + end + return inventory +end + +local function detectPlayerInsert() + previousInventory = getTurtleInventory() + + while true do + os.pullEvent("turtle_inventory") + + local currentInventory = getTurtleInventory() + + if turtleMoveAllowed then + local newlyAddedSlots = {} + + for slot = 1, 16 do + local prev = previousInventory[slot] + local curr = currentInventory[slot] + + if not prev and curr then + table.insert(newlyAddedSlots, slot) + end + end + + if #newlyAddedSlots > 0 then + sendItemAwayMultiple(newlyAddedSlots) + end + end + + previousInventory = currentInventory + end +end + +local function getAIL() + return ail +end + + +return { + detectPlayerInsert = detectPlayerInsert, + sendItemAwayMultiple = sendItemAwayMultiple, + sendItemToSelf = sendItemToSelf, + getTurtleInventory = getTurtleInventory, + sync = sync, + getAIL = getAIL, +} diff --git a/src/modules/ui.lua b/src/modules/ui.lua new file mode 100644 index 0000000..78aad43 --- /dev/null +++ b/src/modules/ui.lua @@ -0,0 +1,71 @@ +local inv = require("modules.inv"); +local PrimeUI = require("lib.primeui").PrimeUI; + +local function runUi() + local search = "" + + local function getFiltered() + local filtered = {} + for name, count in pairs(inv.getAIL().listItemAmounts()) do + if search == "" or string.find(name:lower(), search:lower(), 1, true) then + table.insert(filtered, { name = name, count = count }) + end + end + table.sort(filtered, function(a, b) return a.count > b.count end) + + local refiltered = {} + + for i, filter in ipairs(filtered) do + refiltered[i] = filter.name .. " -> " .. tostring(filter.count) + end + return refiltered + end + + local com = getFiltered("") + + local win = term.current(); + local w , h= term.getSize() + + PrimeUI.clear() + local updateEntries = PrimeUI.selectionBox( + win, 1, 4, w, h-7, + function () + return com + end, + function(option) + local z = option:match("^([^\\s]*)") + + if inv.getAIL().getItem(z) then + inv.sendItemToSelf(z) + end + end + ) + + PrimeUI.inputBox(win, 2, 2, w-2, function (data) + search = data + com = getFiltered() + updateEntries() + end) + + PrimeUI.label(win, 2, h-1, "primeui ui refucked") + PrimeUI.label(win, 2, h-2, "rev. " .. fs.open("storage-solution/version", "r").readAll()) + + local timer = os.startTimer(1) + PrimeUI.addTask(function() + while true do + local _, osTimer = os.pullEvent("timer") + if osTimer == timer then + com = getFiltered() + updateEntries() + timer = os.startTimer(1) + end + end + end) + + PrimeUI.run() +end + + +return { + runUi = runUi +}