diff --git a/.gitignore b/.gitignore index 76467f5..7c553b5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ log.txt config.lua basalt.lua -abstractInvLib.lua stone.json # Jekyll junk files diff --git a/clients/disposal.lua b/clients/disposal.lua new file mode 100644 index 0000000..14b261a --- /dev/null +++ b/clients/disposal.lua @@ -0,0 +1,313 @@ +-- Disposal client for handling item disposal +local modem = peripheral.find("modem", function(name, modem) + return true +end) +local modemName = peripheral.getName(modem) +rednet.open(modemName) +local networkName = modem.getNameLocal() + +---@enum State +local STATES = { + READY = "READY", + ERROR = "ERROR", + BUSY = "BUSY", + DISPOSING = "DISPOSING", + DONE = "DONE", +} + +local state = STATES.READY +local connected = false +local port = 122 +local keepAliveTimeout = 10 +local w, h = term.getSize() +local banner = window.create(term.current(), 1, 1, w, 1) +local panel = window.create(term.current(), 1, 2, w, h - 1) + +local lastStateChange = os.epoch("utc") + +term.redirect(panel) + +modem.open(port) + +local function validateMessage(message) + local valid = type(message) == "table" and message.protocol ~= nil + valid = valid and (message.destination == networkName or message.destination == "*") + valid = valid and message.source ~= nil + return valid +end + +local function getModemMessage(filter, timeout) + local timer + if timeout then + timer = os.startTimer(timeout) + end + while true do + ---@type string, string, integer, integer, any, integer + local event, side, channel, reply, message, distance = os.pullEvent() + if event == "modem_message" and (filter == nil or filter(message)) then + if timeout then + os.cancelTimer(timer) + end + return { + side = side, + channel = channel, + reply = reply, + message = message, + distance = distance + } + elseif event == "timer" and timeout and side == timer then + return + end + end +end + +local lastChar = "|" +local charStateLookup = { + ["|"] = "/", + ["/"] = "-", + ["-"] = "\\", + ["\\"] = "|", +} +local lastCharUpdate = os.epoch("utc") + +local function getActivityChar() + if os.epoch("utc") - lastCharUpdate < 50 then + return lastChar + end + lastCharUpdate = os.epoch("utc") + lastChar = charStateLookup[lastChar] + return lastChar +end + +local function writeBanner() + local x, y = term.getCursorPos() + + banner.setBackgroundColor(colors.gray) + banner.setCursorPos(1, 1) + banner.clear() + if connected then + banner.setTextColor(colors.green) + banner.write("CONNECTED") + else + banner.setTextColor(colors.red) + banner.write("DISCONNECTED") + end + banner.setTextColor(colors.white) + banner.setCursorPos(w - state:len(), 1) + banner.write(state) + term.setCursorPos(x, y) + + local toDisplay = state + if not connected then + toDisplay = "!" .. toDisplay + end + + os.setComputerLabel( + ("%s %s - %s"):format(getActivityChar(), networkName, toDisplay)) +end + +local function keepAlive() + while true do + local modemMessage = getModemMessage(function(message) + return validateMessage(message) and message.protocol == "KEEP_ALIVE" + end, keepAliveTimeout) + connected = modemMessage ~= nil + if modemMessage then + modem.transmit(port, port, { + protocol = "KEEP_ALIVE", + state = state, + source = networkName, + destination = "HOST", + }) + end + writeBanner() + end +end + +local function colWrite(fg, text) + local oldFg = term.getTextColor() + term.setTextColor(fg) + term.write(text) + term.setTextColor(oldFg) +end + +local function saveState() + -- local f = fs.open(".disposal", "wb") + -- f.write(textutils.serialise({state=state,task=task})) + -- f.close() +end + +local lastState +---@param newState State +local function changeState(newState) + if state ~= newState then + lastStateChange = os.epoch("utc") + if newState == "ERROR" then + lastState = state + end + end + state = newState + saveState() + modem.transmit(port, port, { + protocol = "KEEP_ALIVE", + state = state, + source = networkName, + destination = "HOST", + }) + writeBanner() +end + +---@type DisposalTask +local task + +---@class DisposalTask +---@field name string +---@field count integer +---@field jobId string + +local function signalDone() + changeState(STATES.DONE) + modem.transmit(port, port, { + protocol = "DISPOSAL_DONE", + destination = "HOST", + source = networkName, + jobId = task.jobId, + }) +end + +local function tryToDispose() + -- Check if we have the items to dispose + local have = 0 + for slot = 1, 16 do + local item = turtle.getItemDetail(slot) + if item and item.name == task.name then + have = have + item.count + end + end + + if have >= task.count then + -- Dispose items (e.g., drop them) + local remaining = task.count + for slot = 1, 16 do + if remaining <= 0 then break end + local item = turtle.getItemDetail(slot) + if item and item.name == task.name then + local toDrop = math.min(item.count, remaining) + turtle.select(slot) + turtle.drop(toDrop) + remaining = remaining - toDrop + end + end + signalDone() + else + -- Not enough items to dispose + changeState(STATES.ERROR) + end +end + +local protocols = { + DISPOSE = function(message) + task = message.task + changeState(STATES.DISPOSING) + tryToDispose() + end +} + +local interface +local function modemInterface() + while true do + local event = getModemMessage(validateMessage) + assert(event, "Got no message?") + if protocols[event.message.protocol] then + protocols[event.message.protocol](event.message) + end + end +end + +local interfaceLUT +interfaceLUT = { + help = function() + local maxw = 0 + local commandList = {} + for k, v in pairs(interfaceLUT) do + maxw = math.max(maxw, k:len() + 1) + table.insert(commandList, k) + end + local elementW = math.floor(w / maxw) + local formatStr = "%" .. maxw .. "s" + for i, v in ipairs(commandList) do + term.write(formatStr:format(v)) + if (i + 1) % elementW == 0 then + print() + end + end + print() + end, + clear = function() + term.clear() + term.setCursorPos(1, 1) + end, + info = function() + print(("Local network name: %s"):format(networkName)) + end, + reboot = function() + os.reboot() + end, + reset = function() + changeState(STATES.READY) + end +} + +function interface() + print("Disposal turtle ready") + while true do + colWrite(colors.cyan, "] ") + local input = io.read() + if interfaceLUT[input] then + interfaceLUT[input]() + else + colWrite(colors.red, "Invalid command.") + print() + end + end +end + +local function resumeState() + if state == "DISPOSING" then + tryToDispose() + end +end + +local retries = 0 +local function errorChecker() + resumeState() + while true do + if os.epoch("utc") - lastStateChange > 30000 then + lastStateChange = os.epoch("utc") + if state == STATES.DONE then + signalDone() + retries = retries + 1 + if retries > 2 then + print("Done too long") + changeState(STATES.ERROR) + end + elseif state == STATES.DISPOSING then + retries = retries + 1 + if retries > 2 then + print("Disposing too long") + changeState(STATES.ERROR) + end + else + retries = 0 + end + end + os.sleep(1) + writeBanner() + end +end + +writeBanner() +local ok, err = pcall(parallel.waitForAny, interface, keepAlive, modemInterface, errorChecker) + +os.setComputerLabel(("X %s - %s"):format(networkName, "OFFLINE")) +error(err) \ No newline at end of file diff --git a/clients/usageMonitor.lua b/clients/usageMonitor.lua index e78f4ab..7e8d95a 100644 --- a/clients/usageMonitor.lua +++ b/clients/usageMonitor.lua @@ -1,3 +1,4 @@ +-- Settings Setup local monitorSide if not settings.get("misc.monitor") then settings.define("misc.monitor", { description = "Monitor side to display on.", type = "string" }) @@ -6,6 +7,7 @@ if not settings.get("misc.monitor") then settings.set("misc.monitor", monitorSide) settings.save() end + local wirelessMode = fs.exists("websocketLib.lua") if wirelessMode and not settings.get("misc.websocketURL") then settings.define("misc.websocketURL",{ description = "URL of the websocket to use for wireless communication", type = "string" }) @@ -13,12 +15,56 @@ if wirelessMode and not settings.get("misc.websocketURL") then settings.set("misc.websocketURL", read()) settings.save() end -local textScale = 0.5 + + +if not settings.get("misc.style") then + settings.define("misc.style", { description = "Display style: horizontal, vertical, big, text, pie", type = "string" }) + print("Choose display style (horizontal, vertical, big, text, pie):") + local s = read() + if s == "" then s = "horizontal" end + settings.set("misc.style", s) + settings.save() +end + + +if not settings.get("misc.scale") then + settings.define("misc.scale", { description = "Text scale (0.5 to 5)", type = "number" }) + print("Enter text scale (default 0.5):") + local s = read() + local n = tonumber(s) + if not n then n = 0.5 end + settings.set("misc.scale", n) + settings.save() +end + +if not settings.get("misc.percentageCutoff") and settings.get("misc.style") == "pie" then + settings.define("misc.percentageCutoff", { description = "Percentage cutoff (1 to 100)", type = "number" }) + print("Enter percentage cutoff (default 5):") + local s = read() + local n = tonumber(s) / 100 + if not n then n = 0.1 end + settings.set("misc.percentageCutoff", n) + settings.save() +end + + +if not settings.get("misc.theme") then + settings.define("misc.theme", { description = "Display theme: light, dark", type = "string" }) + print("Choose display theme (light, dark):") + local t = read() + if t == "" then t = "light" end + settings.set("misc.theme", t) + settings.save() +end settings.load() + +-- Peripheral Setup monitorSide = settings.get("misc.monitor") local monitor = assert(peripheral.wrap(monitorSide), "Invalid monitor") +local textScale = settings.get("misc.scale") +-- Library Setup local lib if not wirelessMode then lib = require("modemLib") @@ -30,34 +76,195 @@ else lib.connect(websocket) end -local labelFG = colors.black -local labelBG = colors.white -local usedBG = colors.red -local freeBG = colors.gray -monitor.setTextScale(textScale) -local w, h = monitor.getSize() -local barH = h - 2 +-- Configuration Colors +local currentTheme = settings.get("misc.theme") or "light" + +local function setThemeColors() + if currentTheme == "dark" then + -- Dark theme: pure black background, light text + return { + labelFG = colors.white, + labelBG = colors.black, + usedBG = colors.red, + freeBG = colors.gray, + alertColor = colors.orange, + pieColors = {colors.blue, colors.green, colors.orange, colors.purple, colors.cyan, colors.yellow, colors.lime, colors.pink}, + otherColor = colors.lightGray + } + else + -- Light theme: pure white background, dark text + return { + labelFG = colors.black, + labelBG = colors.white, + usedBG = colors.red, + freeBG = colors.gray, + alertColor = colors.orange, + pieColors = {colors.blue, colors.green, colors.orange, colors.purple, colors.cyan, colors.yellow, colors.lime, colors.pink}, + otherColor = colors.lightGray + } + end +end + +local colorsConfig = setThemeColors() +local labelFG = colorsConfig.labelFG +local labelBG = colorsConfig.labelBG +local usedBG = colorsConfig.usedBG +local freeBG = colorsConfig.freeBG +local alertColor = colorsConfig.alertColor +local pieColors = colorsConfig.pieColors +local otherColor = colorsConfig.otherColor + +-- Custom Font Definition +local bigFont = { + ["0"] = { + " ___ ", + " / _ \\ ", + "| | | |", + "| |_| |", + " \\___/ " + }, + ["1"] = { + " _ ", + " / | ", + " | | ", + " | | ", + " |_| " + }, + ["2"] = { + " ____ ", + " |___ \\ ", + " __) | ", + " / __/ ", + "|_____| " + }, + ["3"] = { + " _____ ", + "|___ / ", + " |_ \\ ", + " ___) |", + "|____/ " + }, + ["4"] = { + " _ _ ", + "| || | ", + "| || |_ ", + "|__ _| ", + " |_| " + }, + ["5"] = { + " ____ ", + "| ___| ", + "|___ \\ ", + " ___) |", + "|____/ " + }, + ["6"] = { + " __ ", + " / /_ ", + "| '_ \\ ", + "| (_) |", + " \\___/ " + }, + ["7"] = { + " _____ ", + "|___ |", + " / / ", + " / / ", + "/_/ " + }, + ["8"] = { + " ___ ", + " ( _ ) ", + " / _ \\ ", + "| (_) |", + " \\___/ " + }, + ["9"] = { + " ___ ", + " / _ \\ ", + "| (_) |", + " \\__, |", + " /_/ " + }, + ["%"] = { + " _ __", + "(_)/ /", + " / / ", + " / /_ ", + "/_/(_)" + } +} -local function fillRect(x, y, width, height) - local str = string.rep(" ", width) +-- Drawing Helpers +local function setColors(fg, bg) + monitor.setTextColor(fg) + monitor.setBackgroundColor(bg) +end + +local function fillRect(x, y, width, height, char) + local str = string.rep(char or " ", width) for i = 0, height - 1 do monitor.setCursorPos(x, y + i) monitor.write(str) end end -local setBG = monitor.setBackgroundColor -local setFG = monitor.setTextColor +local function centerText(y, text) + local w, _ = monitor.getSize() + monitor.setCursorPos(math.floor((w - #text) / 2) + 1, y) + monitor.write(text) +end -local function writeUsage() - local usage = lib.getUsage() - setBG(labelBG) - setFG(labelFG) +local function getPercentage(usage) + if usage.total == 0 then return 0 end + return usage.used / usage.total +end + +-- Renders the custom ASCII font +local function drawBigNumbers(text, startY, fg, bg) + setColors(fg, bg) + -- Calculate total width first to center + local totalWidth = 0 + local charGrids = {} + + for i = 1, #text do + local c = string.sub(text, i, i) + local grid = bigFont[c] or bigFont["0"] + table.insert(charGrids, grid) + totalWidth = totalWidth + #grid[1] + end + + -- Add spacing + totalWidth = totalWidth + (#charGrids - 1) + + local w, _ = monitor.getSize() + local startX = math.floor((w - totalWidth) / 2) + 1 + + local currentX = startX + for _, grid in ipairs(charGrids) do + for r = 1, #grid do + monitor.setCursorPos(currentX, startY + r - 1) + monitor.write(grid[r]) + end + currentX = currentX + #grid[1] + 1 + end +end + +-- Style Definitions +local styles = {} + +-- 1. Original Horizontal Bar +styles.horizontal = function(usage, w, h) + local barH = h - 2 + if barH < 1 then barH = 1 end + + -- Header + setColors(labelFG, labelBG) monitor.clear() local slots = string.format("Total %u", usage.total) - monitor.setCursorPos(math.floor((w - #slots) / 2), 1) - monitor.write(slots) + centerText(1, slots) + -- Footer Stats local used = string.format("Used %u", usage.used) monitor.setCursorPos(1, h) monitor.write(used) @@ -66,25 +273,322 @@ local function writeUsage() monitor.setCursorPos(w - #free + 1, h) monitor.write(free) - local usedWidth = math.floor((usage.used / usage.total) * w) - setBG(usedBG) + -- The Bar + local pct = getPercentage(usage) + local usedWidth = math.floor(pct * w) + + setColors(labelFG, usedBG) fillRect(1, 2, usedWidth, barH) - setBG(freeBG) - fillRect(usedWidth + 1, 2, w - usedWidth + 1, barH) - print(1, usedWidth + 1, w - usedWidth + 1, barH) + setColors(labelFG, freeBG) + fillRect(usedWidth + 1, 2, w - usedWidth, barH) +end + +-- 2. Vertical Bar (Cloned style, vertical graph) +styles.vertical = function(usage, w, h) + local barH = h - 2 + if barH < 1 then barH = 1 end + + setColors(labelFG, labelBG) + monitor.clear() + + -- Header + local slots = string.format("Total %u", usage.total) + centerText(1, slots) + + -- Footer Stats + local used = string.format("Used %u", usage.used) + local free = string.format("Free %u", usage.free) + + -- If narrow, stack used/free, otherwise put on same line + if (w < #used + #free + 2) then + monitor.setCursorPos(1, h-1) + monitor.write(used) + monitor.setCursorPos(1, h) + monitor.write(free) + barH = barH - 1 -- Reduce bar height for extra text line + else + monitor.setCursorPos(1, h) + monitor.write(used) + monitor.setCursorPos(w - #free + 1, h) + monitor.write(free) + end + + -- The Vertical Bar + local pct = getPercentage(usage) + local usedHeight = math.floor(pct * barH) + local freeHeight = barH - usedHeight + + -- Draw Free (Top part of bar) + setColors(labelFG, freeBG) + fillRect(1, 2, w, freeHeight) + + -- Draw Used (Bottom part of bar) + setColors(labelFG, usedBG) + fillRect(1, 2 + freeHeight, w, usedHeight) +end + +-- 3. Big Text (Custom Font) +styles.big = function(usage, w, h) + local pct = getPercentage(usage) + + local bg = freeBG + if pct > 0.75 then bg = usedBG + elseif pct > 0.5 then bg = alertColor end + + -- Use theme-aware text color + local textColor = currentTheme == "dark" and colors.white or colors.black + setColors(textColor, bg) + monitor.clear() + + local text = string.format("%d%%", math.floor(pct * 100)) + + -- Center vertically (font is 5 high) + local fontY = math.floor((h - 5) / 2) + 1 + drawBigNumbers(text, fontY, textColor, bg) + + -- Subtext + monitor.setTextScale(0.5) + local w2, h2 = monitor.getSize() + setColors(textColor, bg) + centerText(h2, string.format("%u / %u", usage.used, usage.total)) + monitor.setTextScale(textScale) +end + +-- 4. Text List (Detailed info) +styles.text = function(usage, w, h) + setColors(labelFG, labelBG) + monitor.clear() + + local lines = { + "--- STATUS ---", + "", + "Total: " .. usage.total, + "Used: " .. usage.used, + "Free: " .. usage.free, + "" + } + + local pct = getPercentage(usage) + table.insert(lines, "Full: " .. math.floor(pct * 100) .. "%") + + local startY = math.floor((h - #lines) / 2) + 1 + if startY < 1 then startY = 1 end + + for i, line in ipairs(lines) do + monitor.setCursorPos(2, startY + i - 1) + monitor.write(line) + end +end + +-- 5. Pie Chart (Top Items) +styles.pie = function(usage, w, h) + setColors(labelFG, labelBG) + monitor.clear() + + -- Draw Header and Footer Stats + local slots = string.format("Total %u", usage.total) + centerText(1, slots) + + local used = string.format("Used %u", usage.used) + monitor.setCursorPos(1, h) + monitor.write(used) + + local free = string.format("Free %u", usage.free) + monitor.setCursorPos(w - #free + 1, h) + monitor.write(free) + + if not usage.items then + centerText(math.floor(h/2), "No item data") + return + end + + -- Calculate Total Item Count from items list for accurate percentages + local totalItems = 0 + for _, item in ipairs(usage.items) do + totalItems = totalItems + (item.count or 0) + end + + if totalItems == 0 then + centerText(math.floor(h/2), "Inventory Empty") + return + end + + -- Sort items by count desc + table.sort(usage.items, function(a,b) return (a.count or 0) > (b.count or 0) end) + + -- Create Slices (threshold 10%) + local slices = {} + local otherCount = 0 + local colorIdx = 1 + + for _, item in ipairs(usage.items) do + local pct = item.count / totalItems + if pct >= settings.get("misc.percentageCutoff") then + table.insert(slices, { + label = item.displayName or item.name or "Unknown", + pct = pct, + color = pieColors[colorIdx] or colors.white + }) + colorIdx = (colorIdx % #pieColors) + 1 + else + otherCount = otherCount + item.count + end + end + + if otherCount > 0 then + table.insert(slices, { + label = "Other", + pct = otherCount / totalItems, + color = otherColor + }) + end + + -- Calculate screen geometry + -- Aspect ratio correction: Circle is drawn 1.5x wider than tall to look round on CC monitors + + -- Constrain by height (leave 1 line top/bottom for header/footer) + -- Usable height = h - 2 + local usableH = h - 2 + if usableH < 1 then usableH = 1 end + + local radiusH = usableH / 2 + + -- Constrain by width (use ~50% of width for pie, leave 50% for legend) + local radiusW = (w * 0.5) / 3 + + local radius = math.min(radiusH, radiusW) + if radius < 2 then radius = 2 end + + local centerX = math.floor(radius * 1.5) + 2 -- Shift right slightly + local centerY = math.floor(usableH / 2) + 2 -- +2 because y starts at 2 (after header) + + -- Draw Pie + -- Iterate bounding box of circle + for y = centerY - radius, centerY + radius do + for x = centerX - (radius*1.5), centerX + (radius*1.5) do + local dx = (x - centerX) / 1.5 -- Correct aspect ratio (CC pixels are tall) + local dy = (y - centerY) + + local dist = math.sqrt(dx*dx + dy*dy) + if dist <= radius then + -- Calculate Angle (-pi to pi) + local angle = math.atan2(dy, dx) + -- Normalize to 0 to 1 + local normalizedAngle = (angle + math.pi) / (2 * math.pi) + + -- Find which slice covers this angle + local currentPct = 0 + local pixelColor = labelBG + for _, slice in ipairs(slices) do + if normalizedAngle >= currentPct and normalizedAngle < (currentPct + slice.pct) then + pixelColor = slice.color + break + end + currentPct = currentPct + slice.pct + end + + -- Draw only if inside bounds and not overwriting header/footer + if x >= 1 and x <= w and y >= 2 and y <= h - 1 then + monitor.setBackgroundColor(pixelColor) + monitor.setCursorPos(x, y) + monitor.write(" ") + end + end + end + end + + -- Draw Legend (Right side) + local legendX = math.floor(centerX + (radius * 1.5)) + 2 + local legendY = math.floor((usableH - #slices) / 2) + 2 + if legendY < 2 then legendY = 2 end + + if legendX < w then + for i, slice in ipairs(slices) do + local yPos = legendY + i - 1 + if yPos <= h - 1 then + monitor.setCursorPos(legendX, yPos) + monitor.setBackgroundColor(slice.color) + monitor.write(" ") -- Color swatch + monitor.setBackgroundColor(labelBG) + monitor.setTextColor(labelFG) + local pctStr = math.floor(slice.pct * 100) .. "%" + monitor.write(" " .. pctStr .. " " .. slice.label) + end + end + end +end + +-- Main Logic +local function writeUsage(providedItems) + local usage = lib.getUsage() + + -- Refresh settings in case they changed while running + settings.load() + local currentStyle = settings.get("misc.style") + local currentScale = settings.get("misc.scale") + local currentTheme = settings.get("misc.theme") or "light" + + -- Update theme colors if theme changed + local newTheme = settings.get("misc.theme") or "light" + if newTheme ~= currentTheme then + currentTheme = newTheme + local colorsConfig = setThemeColors() + labelFG = colorsConfig.labelFG + labelBG = colorsConfig.labelBG + usedBG = colorsConfig.usedBG + freeBG = colorsConfig.freeBG + alertColor = colorsConfig.alertColor + pieColors = colorsConfig.pieColors + otherColor = colorsConfig.otherColor + end + + -- If in pie mode, we need item data. + -- Use providedItems (from update event) or fetch fresh list if missing. + if currentStyle == "pie" then + if providedItems then + usage.items = providedItems + elseif lib.list then + -- Fallback for initial render or manual refresh + -- We wrap in pcall just in case lib.list isn't available/fails + local ok, res = pcall(lib.list) + if ok then usage.items = res end + end + end + + monitor.setTextScale(currentScale) + local w, h = monitor.getSize() + + local drawFunc = styles[currentStyle] or styles.horizontal + + -- Protected call to prevent crashing on drawing errors + local ok, err = pcall(drawFunc, usage, w, h) + if not ok then + -- Use theme-aware colors for error display + local errorBG = currentTheme == "dark" and colors.black or colors.white + local errorFG = currentTheme == "dark" and colors.red or colors.red + monitor.setBackgroundColor(errorBG) + monitor.clear() + monitor.setCursorPos(1,1) + monitor.setTextColor(errorFG) + print("Draw Error: " .. tostring(err)) + monitor.write("Style Error") + end end local function handleUpdates() while true do local _, list = os.pullEvent("update") - writeUsage() + writeUsage(list) end end +-- Initial Draw writeUsage() +-- Loop Setup local watchdogAvaliable = fs.exists("watchdogLib.lua") local funcs = {lib.subscribe, handleUpdates} + if watchdogAvaliable then local watchdogLib = require '.watchdogLib' local wdFunc = watchdogLib.watchdogLoopFromSettings() @@ -92,4 +596,5 @@ if watchdogAvaliable then funcs[#funcs+1] = wdFunc end end -parallel.waitForAny(table.unpack(funcs)) + +parallel.waitForAny(table.unpack(funcs)) \ No newline at end of file diff --git a/installer.lua b/installer.lua index 8445a78..6be8d03 100644 --- a/installer.lua +++ b/installer.lua @@ -60,7 +60,7 @@ local baseInstall = { name = "Base MISC", files = { ["startup.lua"] = fromRepository "storage.lua", - ["abstractInvLib.lua"] = fromURL "https://gist.githubusercontent.com/ShreksHellraiser/57ef0f52a93304a17a9eaea21f431de6/raw/07c3322a5fa0d628e558e19017295728e4ee2e8d/abstractInvLib.lua", -- TODO change this + ["abstractInvLib.lua"] = fromURL "lib/abstractInvLib.lua", -- external library (update when upstream changes) ["common.lua"] = fromRepository "common.lua", modules = { ["inventory.lua"] = fromRepository "modules/inventory.lua", diff --git a/lib/abstractInvLib.lua b/lib/abstractInvLib.lua new file mode 100644 index 0000000..4dc868e --- /dev/null +++ b/lib/abstractInvLib.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/modules/crafting.lua b/modules/crafting.lua index 1fe79d8..e7b55bc 100644 --- a/modules/crafting.lua +++ b/modules/crafting.lua @@ -4,7 +4,7 @@ local common = require("common") return { id = "crafting", version = "1.4.1", - config = { + config = { tagLookup = { type = "table", description = "Force a given item to be used for a tag lookup. Map from tag->item.", @@ -25,6 +25,16 @@ return { type = "number", description = "Interval between cleanup in seconds", default = 60, + }, + autoCraftingRules = { + type = "table", + description = "Automatic crafting rules when item count is reached table", + default = {} + }, + autoSmeltingRules = { + type = "table", + description = "Automatic smelting rules when item count is reached table", + default = {} } }, dependencies = { @@ -205,6 +215,19 @@ return { local function deallocateItems(name, amount, taskId) common.enforceType(name, 1, "string") common.enforceType(amount, 2, "integer") + + -- Ensure reservedItems[name] and reservedItems[name][taskId] exist + if not reservedItems[name] or not reservedItems[name][taskId] then + -- Items were not reserved, this can happen when crafting completes successfully + -- and items were already consumed, or when tasks are cleaned up properly + if log then + craftLogger:debug("Attempt to deallocate items that are not reserved (name: %s, amount: %d, taskId: %s)", name, amount, taskId) + else + print("Attempt to deallocate items that are not reserved") + end + return 0 + end + reservedItems[name][taskId] = reservedItems[name][taskId] - amount assert(reservedItems[name][taskId] >= 0, "We have negative items reserved?") if reservedItems[name][taskId] == 0 then @@ -937,7 +960,14 @@ return { craftLogger:debug("Nodes processed in crafting tick.") saveTaskLookup() end - os.sleep(config.crafting.tickInterval.value) + -- Use default value if config is not properly initialized + local sleepTime = 1 + if config.crafting and config.crafting.tickInterval and type(config.crafting.tickInterval.value) == "number" then + sleepTime = config.crafting.tickInterval.value + else + craftLogger:warn("Using default sleep time for crafting tick (config.crafting.tickInterval.value not available)") + end + os.sleep(sleepTime) end end @@ -1074,7 +1104,14 @@ return { end local function cleanupHandler() while true do - sleep(config.crafting.cleanupInterval.value) + -- Use default value if config is not properly initialized + local sleepTime = 60 + if config.crafting and config.crafting.cleanupInterval and type(config.crafting.cleanupInterval.value) == "number" then + sleepTime = config.crafting.cleanupInterval.value + else + cleanupLogger:warn("Using default sleep time for cleanup handler (config.crafting.cleanupInterval.value not available)") + end + os.sleep(sleepTime) cleanupLogger:debug("Performing cleanup!") for k, v in pairs(pendingJobs) do if v.time + 200000 < os.epoch("utc") then @@ -1100,6 +1137,105 @@ return { end end + ---Auto-crafting/smelting functionality + local autoCraftLogger = setmetatable({}, { + __index = function() + return function() + end + end + }) + if log then + autoCraftLogger = log.interface.logger("crafting", "auto_craft") + end + + ---Check if an item should be auto-crafted based on current inventory count + ---@param name string + ---@return boolean shouldCraft + ---@return integer neededCount + local function shouldAutoCraftItem(name) + local rules = config.crafting and config.crafting.autoCraftingRules and config.crafting.autoCraftingRules.value or {} + local rule = rules[name] + if not rule or type(rule) ~= "table" or type(rule.threshold) ~= "number" then return false, 0 end + + local currentCount = getCount(name) + if currentCount < rule.threshold then + return true, rule.threshold - currentCount + end + return false, 0 + end + + ---Check if an item should be auto-smelted based on current inventory count + ---@param name string + ---@return boolean shouldSmelt + ---@return integer neededCount + local function shouldAutoSmeltItem(name) + local rules = config.crafting and config.crafting.autoSmeltingRules and config.crafting.autoSmeltingRules.value or {} + local rule = rules[name] + if not rule or type(rule) ~= "table" or type(rule.threshold) ~= "number" or not rule.output then return false, 0 end + + local currentCount = getCount(name) + if currentCount < rule.threshold then + return true, rule.threshold - currentCount + end + return false, 0 + end + + ---Check all items and trigger auto-crafting/smelting + local function checkAutoCrafting() + autoCraftLogger:debug("Checking for auto-crafting/smelting opportunities...") + + -- Check auto-crafting rules with proper null checks + local autoCraftingRules = config.crafting and config.crafting.autoCraftingRules and config.crafting.autoCraftingRules.value or {} + for item, rule in pairs(autoCraftingRules) do + if type(rule) == "table" and type(rule.threshold) == "number" then + local shouldCraft, neededCount = shouldAutoCraftItem(item) + if shouldCraft then + autoCraftLogger:info("Auto-crafting %u %s(s) (threshold: %u)", neededCount, item, rule.threshold) + local jobInfo = requestCraft(item, neededCount) + if jobInfo.success then + startCraft(jobInfo.jobId) + end + end + else + autoCraftLogger:warn("Invalid auto-crafting rule for item %s", item) + end + end + + -- Check auto-smelting rules with proper null checks + local autoSmeltingRules = config.crafting and config.crafting.autoSmeltingRules and config.crafting.autoSmeltingRules.value or {} + for item, rule in pairs(autoSmeltingRules) do + if type(rule) == "table" and type(rule.threshold) == "number" and rule.output then + local shouldSmelt, neededCount = shouldAutoSmeltItem(item) + if shouldSmelt then + autoCraftLogger:info("Auto-smelting to produce %u %s(s) (threshold: %u)", neededCount, rule.output, rule.threshold) + -- This would need integration with furnace module + -- For now, we'll just request crafting of the output item + local jobInfo = requestCraft(rule.output, neededCount) + if jobInfo.success then + startCraft(jobInfo.jobId) + end + end + else + autoCraftLogger:warn("Invalid auto-smelting rule for item %s", item) + end + end + end + + ---Auto-crafting/smelting checker loop + local function autoCraftChecker() + while true do + -- Use default value if config is not properly initialized + local sleepTime = 10 + if config.crafting and config.crafting.tickInterval and type(config.crafting.tickInterval.value) == "number" then + sleepTime = config.crafting.tickInterval.value * 10 + else + autoCraftLogger:warn("Using default sleep time for auto-craft checker (config.crafting.tickInterval.value not available)") + end + sleep(sleepTime) -- Check every 10 crafting ticks (or default) + checkAutoCrafting() + end + end + local function jsonFileImport() print("JSON file importing ready..") while true do @@ -1129,7 +1265,7 @@ return { loadReservedItems() loadCachedTags() loadPendingJobs() - parallel.waitForAny(tickCrafting, inventoryTransferListener, jsonFileImport, cleanupHandler) + parallel.waitForAny(tickCrafting, inventoryTransferListener, jsonFileImport, cleanupHandler, autoCraftChecker) end, requestCraft = requestCraft, diff --git a/modules/disposal.lua b/modules/disposal.lua new file mode 100644 index 0000000..70e7857 --- /dev/null +++ b/modules/disposal.lua @@ -0,0 +1,127 @@ +--- Disposal module for handling item disposal +---@class modules.disposal +return { + id = "disposal", + version = "1.0.0", + config = { + disposalItems = { + type = "table", + description = "Items to dispose and their thresholds table", + default = {} + }, + checkFrequency = { + type = "number", + description = "Time in seconds to wait between checking for items to dispose", + default = 60 + } + }, + dependencies = { + logger = { min = "1.1", optional = true }, + inventory = { min = "1.1" } + }, + ---@param loaded {logger: modules.logger|nil, inventory: modules.inventory} + init = function(loaded, config) + local log = loaded.logger + local inventory = loaded.inventory.interface + + local disposalLogger = setmetatable({}, { + __index = function() + return function() end + end + }) + if log then + disposalLogger = log.interface.logger("disposal", "main") + end + + ---@type table item name -> threshold + local disposalThresholds = {} + + local function updateDisposalThresholds() + disposalThresholds = {} + for item, threshold in pairs(config.disposal.disposalItems.value) do + disposalThresholds[item] = threshold + end + end + + ---@type table + local disposalHandlers = {} + + ---@param name string + ---@param handler fun(name: string, count: integer): boolean + local function addDisposalHandler(name, handler) + disposalHandlers[name] = handler + end + + ---Check if an item should be disposed based on current inventory count + ---@param name string + ---@return boolean shouldDispose + ---@return integer excessCount + local function shouldDisposeItem(name) + local threshold = disposalThresholds[name] + if not threshold then return false, 0 end + + local currentCount = inventory.getCount(name) + if currentCount > threshold then + return true, currentCount - threshold + end + return false, 0 + end + + ---Request disposal of items + ---@param name string + ---@param count integer + ---@return boolean success + local function requestDisposal(name, count) + disposalLogger:info("Requesting disposal of %u %s(s)", count, name) + + if disposalHandlers[name] then + return disposalHandlers[name](name, count) + else + disposalLogger:warn("No disposal handler for item %s", name) + return false + end + end + + ---Check all items and dispose excess + local function checkAndDispose() + disposalLogger:debug("Checking for items to dispose...") + + for item, threshold in pairs(disposalThresholds) do + local shouldDispose, excessCount = shouldDisposeItem(item) + if shouldDispose then + disposalLogger:info("Found %u excess %s(s) to dispose (threshold: %u)", excessCount, item, threshold) + requestDisposal(item, excessCount) + end + end + end + + ---Main disposal checker loop + local function disposalChecker() + while true do + sleep(config.disposal.checkFrequency.value) + checkAndDispose() + end + end + + ---Handle disposal completion + ---@param jobId string + local function handleDisposalDone(jobId) + disposalLogger:info("Disposal job %s completed", jobId) + end + + return { + start = function() + updateDisposalThresholds() + disposalChecker() + end, + + interface = { + addDisposalHandler = addDisposalHandler, + requestDisposal = requestDisposal, + shouldDisposeItem = shouldDisposeItem, + handleDisposalDone = handleDisposalDone, + updateDisposalThresholds = updateDisposalThresholds + } + } + end +} \ No newline at end of file diff --git a/modules/inventory.lua b/modules/inventory.lua index e21be93..2758a92 100644 --- a/modules/inventory.lua +++ b/modules/inventory.lua @@ -165,10 +165,17 @@ return { local retVal = table.pack(pcall(function() return storage[transfer[2]](table.unpack(transfer, 3, transfer.n)) end)) if not retVal[1] then logger:error("Transfer %s %s failed with %s", transfer[1], transfer[2], retVal[2]) - error(retVal[2]) + -- Check if the error is due to a non-existent source, if so skip instead of crashing + if string.find(retVal[2], "does not exist") then + logger:warn("Skipping transfer %s %s - source does not exist", transfer[1], transfer[2]) + os.queueEvent("inventoryFinished", transfer[1], 0) -- Return 0 to indicate no items transferred + else + error(retVal[2]) + end + else + logger:debug("Transfer %s %s finished, returned %s", transfer[1], transfer[2], retVal[2]) + os.queueEvent("inventoryFinished", transfer[1], table.unpack(retVal, 2)) end - logger:debug("Transfer %s %s finished, returned %s", transfer[1], transfer[2], retVal[2]) - os.queueEvent("inventoryFinished", transfer[1], table.unpack(retVal, 2)) end if config.inventory.defragEachTransfer.value then defrag()