From b760dcdbbb3199e6a5c9fab32105a3828b6024be Mon Sep 17 00:00:00 2001 From: Gabriel Tofvesson Date: Thu, 3 Oct 2024 16:14:56 +0200 Subject: [PATCH] Improve inventory management implementation --- items.lua | 352 ------------------------------------------ storage/chest.lua | 163 +++++++++++++++++++ storage/init.lua | 100 ++++++++++++ storage/itemstack.lua | 211 +++++++++++++++++++++++++ 4 files changed, 474 insertions(+), 352 deletions(-) delete mode 100644 items.lua create mode 100644 storage/chest.lua create mode 100644 storage/init.lua create mode 100644 storage/itemstack.lua diff --git a/items.lua b/items.lua deleted file mode 100644 index 31cb5dc..0000000 --- a/items.lua +++ /dev/null @@ -1,352 +0,0 @@ -local STORAGE_NODE_NAME = "minecraft:trapped_chest" -local STORAGE_STORE_PREFIX = "minecraft:chest_" -local node = peripheral.find(STORAGE_NODE_NAME) -local store = {} -for _,v in ipairs(peripheral.getNames()) do - if #v >= #STORAGE_STORE_PREFIX and v:sub(1,#STORAGE_STORE_PREFIX) == STORAGE_STORE_PREFIX then - store[v] = peripheral.wrap(v) - end -end - -local function countPopulated(chest) - local count = 0 - local list = chest.list() - for _,_ in ipairs(list) do - count = count + 1 - end - for _,_ in pairs(list) do - count = count + 1 - end - return count -end - -local function countNamed(tbl) - local count = 0 - for _,_ in pairs(tbl) do - count = count + 1 - end - return count -end - -local function cacheFromItemDetail(detail, chest, slot) - if detail == nil then - return { - maxCount = chest.getItemLimit(slot) - } - end - - return { - name = detail.name, - enchantments = detail.enchantments, - count = detail.count, - maxCount = detail.maxCount, - displayName = detail.displayName, - damage = detail.damage, - maxDamage = detail.maxDamage - } -end - -local function cacheFromSlot(chest, slot) - return cacheFromItemDetail(chest.getItemDetail(slot), chest, slot) -end - -local function scanChest(chest) - local entry = {} - for slot=1,chest.size() do - table.insert( - entry, - cacheFromSlot(chest, slot) - ) - end - return entry -end - -local Cache = {} - --- cacheLoader?: function(periph_name, periph_inv): { [number]: { maxCount: number } } | nil -function Cache.makeCache(cacheLoader) - local count = countNamed(store) - local state = {} - local progress = 0 - for name,chest in pairs(store) do - progress = progress + 1 - local percent = ""..((progress / count) * 100) - print(percent:sub(1, math.min(#percent, 6))..("0"):rep(6 - #percent).."% ("..progress.."/"..count..")") - - local entry = nil - if countPopulated(chest) == 0 and type(cacheLoader) == "function" then - entry = cacheLoader(name, chest) - end - - if entry == nil then - print("Found populated chest: "..name) - entry = scanChest(chest) - end - entry.index = name - state[name] = entry - end - - --[[ - state: { - [str]: { - index: str, - - [number]: { - name?: str, - enchantments?: { - [str]: str - }, - count?: number, -- if nil, then slot is empty - maxCount: number, - displayName?: str, - damage?: number, - maxDamage?: number - } - ... - } - } - --]] - - return Cache:from(state) -end - -function Cache:reloadSlot(chestName, slot) - local chest = self[chestName] - if chest == nil then - return false - end - - chest[slot] = cacheFromSlot(store[chestName], slot) - return true -end - -function Cache:from(o) - setmetatable(o, self) - self.__index = self - return o -end - -function Cache:save(fileName) - local file = io.open(fileName, "w+") - if file == nil then - return false - end - local x = self - x.__index = nil - setmetatable(x, nil) - file:write(textutils.serialize(x, { compact = true })) - file:close() - Cache:from(x) - return true -end - --- cacheLoader?: function(periph_name, periph_inv): { [number]: { maxCount: number } } | nil -function Cache.loadOrBuild(fileName, cacheLoader) - local file = fs.open(fileName, "r") - if file == nil then - print("Building cache...") - return Cache.makeCache(cacheLoader) - end - - local obj = textutils.unserialise(file.read(fs.getSize(fileName))) - if obj ~= nil then - return Cache:from(obj) - else - print("Malformed cache file! Building cache...") - return Cache.makeCache(cacheLoader) - end -end - --- predicate: function(itemDetail) -> bool -function Cache:find(predicate) - local slots = {} - - if type(predicate) ~= "function" then - return slots - end - - for name,chest in pairs(self) do - for slot,detail in ipairs(chest) do - if predicate(detail) then - -- Flat to make sorting is easier - table.insert(slots, { - chest = name, - slot = slot, - name = detail.name, - enchantments = detail.enchantments, - count = detail.count == nil and 0 or detail.count, - maxCount = detail.maxCount, - displayName = detail.displayName, - damage = detail.damage, - maxDamage = detail.maxDamage - }) - end - end - end - return slots -end - -local function checkField(base, check) - if type(base) == "function" then - return base(check) - end - - local baseType = type(base) - if baseType == "nil" then - return true - elseif baseType ~= type(check) then - return false - elseif baseType == "table" then - for i,v in ipairs(base) do - if not checkField(v, check[i]) then - return false - end - end - for k,v in pairs(base) do - if not checkField(v, check[k]) then - return false - end - end - return true - else - return base == check - end -end - -function FILTER_MATCHING_SLOTS(target) - return function(detail) - return detail ~= nil and detail.count ~= nil and (target == nil or (checkField(target.name, detail.name) and - checkField(target.displayName, detail.displayName) and - checkField(target.damage, detail.damage) and - checkField(target.enchantments, detail.enchantments))) - end -end - -function Cache:findEmptySlots() - return self:find(function(detail) - return detail.count == nil and detail.maxCount > 0 - end) -end - -function Cache:findMatchingSlots(target) - return self:find(FILTER_MATCHING_SLOTS(target)) -end - -function Cache:findAllowedSlots(target) - local matchingSlotsFunc = FILTER_MATCHING_SLOTS(target) - local slots = self:find(function(detail) - return detail.count == nil or (detail.count < detail.maxCount and matchingSlotsFunc(detail)) - end) - table.sort(slots, function (a, b) - return (a.count ~= nil and (b.count == nil or a.maxCount > b.maxCount or (a.maxCount == b.maxCount and a.count > b.count))) - end) - return slots -end - -function Cache:insertStack(nodeSlot) - local detail = node.getItemDetail(nodeSlot) - if type(detail) ~= "table" then - return true, 0 - end - - local count = 0 - local allowedSlots = self:findAllowedSlots(detail) - for _,slot in ipairs(allowedSlots) do - if count == detail.count then - break - end - - local slotCap = math.min(slot.maxCount - slot.count, detail.count - count) - local result, change = pcall(node.pushItems, slot.chest, nodeSlot, slotCap, slot.slot) - if not result then - print("Slot insert error for chest \""..slot.chest.."\"! Make sure it's still attached to the controller: "..change) - else - self:reloadSlot(slot.chest, slot.slot) - count = count + change - end - end - - return count == detail.count, count -end - -function Cache:extractStack(detail) - local foundSlots = self:findMatchingSlots(detail) - -- Prioritize smaller stacks in order to create more empty slots when possible - table.sort(foundSlots, function (a, b) - return a.count < b.count - end) - - local count = 0 - for index,slot in ipairs(foundSlots) do - if detail ~= nil and count == detail.count then - break - end - - local slotCap = (detail == nil or detail.count == nil) and slot.count or math.min(slot.count, detail.count) - local result, change = pcall(node.pullItems, slot.chest, slot.slot, slotCap) - if not result then - print("Slot extract error for chest \""..slot.chest.."\"! Make sure it's still attached to the controller: "..change) - else - if change == slot.count then - self:reloadSlot(slot.chest, slot.slot) - else - slot.count = slot.count - change - end - count = count + change - end - end - - return (detail == nil or detail.count == nil) or (detail.count == count), count -end - -local function clone(o) - if type(o) == "table" then - local copy = {} - for k,v in pairs(o) do - copy[clone(k)] = clone(v) - end - - for i,v in ipairs(o) do - copy[i] = clone(v) - end - - return copy - end - - return o -end - - ---[[ -Generates blank chest cache entries for predefined chest types -Current definitions: - - minecraft:chest - - minecraft:trapped_chest ---]] -local function QUICKLOAD(name, chest) - local DEFS = { - { - prefix = "minecraft:chest", - entry = { maxCount = 64 } - }, - { - prefix = "minecraft:trapped_chest", - entry = { maxCount = 64 } - } - } - - for _,def in ipairs(DEFS) do - if #name >= #def.prefix and name:sub(1,#def.prefix) == def.prefix then - local cacheState = {} - for _=1,chest.size() do - table.insert(cacheState, clone(def.entry)) - end - return cacheState - end - end - - -- No quickload def found. Controller must slow-scan inventory - return nil -end - -return function() return Cache, QUICKLOAD end \ No newline at end of file diff --git a/storage/chest.lua b/storage/chest.lua new file mode 100644 index 0000000..a54f8a9 --- /dev/null +++ b/storage/chest.lua @@ -0,0 +1,163 @@ +local ItemStack = require("storage.itemstack") + +local Chest = {} + +-- Homogeneity allows chest scan to clone empty itemDetail slot to all empty slots in chest +function Chest:fromPeripheral(name, homogeneous) + local chest = peripheral.wrap(name) + if chest == nil then + return nil + end + + local obj = { + name = name, + chest = chest, + size = chest.size(), + homogeneous = homogeneous + } + setmetatable(obj, self) + obj.__index = obj + + return obj +end + +function Chest:rescan() + -- Clear stack data + for i=1,self:getSize() do + self[i] = nil + end + + self.size = self.chest.size() + local emptyStack = nil + local listData = self.chest.list() + for i=1,self:getSize() do + local data = listData[i] + local stack + if data == nil and self.homogeneous then + -- Empty stack + if emptyStack == nil then + emptyStack = ItemStack:fromDetail(self.chest, self.chest.getItemDetail(i), i) + if not emptyStack:isEmpty() then + error("Expected empty stack detail, but got populated stack!") + end + end + stack = emptyStack:clone(i) + else + stack = ItemStack:fromDetail(self.chest, self.chest.getItemDetail(i), i) + end + + self[i] = stack + end +end + +function Chest:toSerializable() + local ser = { + name = self:getName(), + size = self:getSize(), + homogeneous = self.homogeneous + } + + local skipEmpty = false + for i,v in ipairs(self) do + local stackSer = v:toSerializable() + + -- Skip all but the first homogeneous slot + if stackSer:isEmpty() then + if skipEmpty then + goto continue + end + + skipEmpty = self.homogeneous + end + ser[i] = stackSer + ::continue:: + end + + return ser +end + +function Chest:fromSerializable(ser, inv) + -- Auto-wrap if inventory is not supplied + if inv == nil then + inv = peripheral.wrap(ser.name) + if inv == nil then + return nil + end + end + + local obj = { + name = ser.name, + size = ser.size, + homogeneous = ser.homogeneous, + chest = inv + } + + local emptyStack = nil + for i=1,ser.size do + local stackSer = ser[i] + local stack + if stackSer == nil then + if emptyStack == nil then + -- Missing inventory slot data! Must get item detail + stack = ItemStack:fromDetail(inv, inv.getItemDetail(i), i) + else + stack = emptyStack:clone(i) + end + else + stack = ItemStack:fromSerializable(stackSer, inv) + end + + if ser.homogeneous and emptyStack == nil and stack:isEmpty() then + emptyStack = stackSer + end + + obj[i] = stack + end + + setmetatable(obj, self) + obj.__index = obj + + return obj +end + +function Chest:hasChanged(thorough) + local mySize = self:getSize() + if mySize ~= self.chest.size() then + return true + end + + local listData = self.chest.list() + for i=1,mySize do + if (listData[i] == nil) ~= (self[i] == nil) then + return true + end + + if listData[i] ~= nil and self[i]:hasChanged(listData[i], thorough) then + return true + end + end + + return false +end + +function Chest:getSize() + return self.size +end + +function Chest:getName() + return self.name +end + +function Chest:find(query) + local result = {} + + for i=1,self:getSize() do + if self[i]:matches(query) then + table.insert(result, self[i]) + end + end + + return result +end + +return Chest \ No newline at end of file diff --git a/storage/init.lua b/storage/init.lua new file mode 100644 index 0000000..d63c2c4 --- /dev/null +++ b/storage/init.lua @@ -0,0 +1,100 @@ +local Chest = require("storage.chest") +local Storage = {} + +function Storage.assumeHomogeneous(names) + local mappings = {} + for _,name in ipairs(names) do + mappings[name] = true + end + return mappings +end + +function Storage:fromPeripherals(names) + local obj = { + count = #names + } + for name,homogeneous in names do + table.insert(obj, Chest:fromPeripheral(name, homogeneous)) + end + + setmetatable(obj, self) + obj.__index = obj + + return obj +end + +function Storage:toSerializable() + local ser = { + count = self.count + } + for _,chest in ipairs(self) do + table.insert(ser, chest:toSerializable()) + end + + return ser +end + +function Storage:fromSerializable(ser) + local obj = { count = ser.count } + + for _,chestSer in ipairs(ser) do + local chest = Chest:fromSerializable(chestSer) + if chest ~= nil then + table.insert(obj, chest) + end + end + + setmetatable(obj, self) + obj.__index = obj + + return obj +end + +function Storage:hasChest(name) + for _,chest in ipairs(self) do + if chest:getName() == name then + return true, chest + end + end + return false, nil +end + +function Storage:attach(name, homogeneous) + if self:hasChest(name) then + return + end + + table.insert(self, Chest:fromPeripheral(name, homogeneous)) +end + +function Storage:find(query) + local result = {} + for _,chest in ipairs(self) do + for _,stack in chest:find(query) do + table.insert(result, stack) + end + end + + return result +end + +-- Find all stacks eligible to accept the given query +function Storage:findInsertTargets(query) + local result = self:find(function(stack) return stack:isEmpty() or stack:matches(query) end) + + -- Insertion should prioritize filling populated stacks + table.sort(result, function(a, b) return a:getcount() > b:getCount() end) + + return result +end + +function Storage:findExtractTargets(query) + local result = self:find(query) + + -- Extraction should prioritize emptying populated stacks + table.sort(result, function(a, b) return a:getcount() < b:getCount() end) + + return result +end + +return Storage \ No newline at end of file diff --git a/storage/itemstack.lua b/storage/itemstack.lua new file mode 100644 index 0000000..e2476cd --- /dev/null +++ b/storage/itemstack.lua @@ -0,0 +1,211 @@ +local ItemStack = {} + +function ItemStack:fromDetail(inv, detail, slot) + local obj = { + slot = slot, + inv = inv + } + + if detail == nil then + obj.maxCount = inv.getItemLimit(slot) + else + obj.name = detail.name + obj.damage = detail.damage + obj.maxDamage = detail.maxDamage + obj.count = detail.count + obj.maxCount = detail.maxCount + obj.enchantments = detail.enchantments + end + + setmetatable(obj, self) + obj.__index = obj + + return obj +end + +function ItemStack:clone(withSlot) + local obj = { + slot = withSlot or self.slot, + inv = self.inv, + name = self.name, + damage = self.damage, + maxDamage = self.maxDamage, + count = self.count, + maxCount = self.maxCount, + enchantments = self.enchantments + } + + setmetatable(obj, self) + obj.__index = obj + + return obj +end + +function ItemStack:toSerializable() + local ser = { + slot = self.slot, + maxCount = self.maxCount + } + + if self.count ~= nil then + -- Not empty + ser.name = self.name + ser.damage = self.damage + ser.maxDamage = self.maxDamage + ser.count = self.count + ser.enchantments = self.enchantments + end + + return ser +end + +function ItemStack:fromSerializable(ser, inv) + ser.inv = inv + + setmetatable(ser, self) + ser.__index = ser + + return ser +end + +function ItemStack:getCount() + return self.count or 0 +end + +function ItemStack:isEmpty() + return self.count == nil or self.count == 0 +end + +function ItemStack:hasChanged(listObj, thorough) + local listItem = listObj[self.slot] + if listItem == nil or listItem.name ~= self.name or listItem.count ~= self.count then + return true + end + + return thorough and (self ~= self:fromDetail(self.inv, self.inv.getItemDetail(self.slot), self.slot)) +end + +function ItemStack:_modify(countDelta, stack) + local newCount = self.count + countDelta + if newCount < 0 then + error("ERROR: New stack count is negative: "..newCount) + end + + if newCount == 0 then + -- Clear data + self.maxCount = self.inv.getItemLimit(self.slot) + self.name = nil + self.damage = nil + self.maxDamage = nil + self.count = nil + self.maxCount = nil + self.enchantments = nil + else + -- If stack is empty, copy stack data from source + if self:isEmpty() then + self.maxCount = stack.maxCount + self.name = stack.name + self.damage = stack.damage + self.maxDamage = stack.maxDamage + self.maxCount = stack.maxCount + self.enchantments = stack.enchantments + end + + self.count = newCount + end +end + +function ItemStack:transferTo(target, count) + local cap = math.min(count, target.maxCount - target.getCount(), self:getCount()) + + -- If we can't transfer any data, then + if cap == 0 then + return count == 0, 0 + end + + local result, xfer = pcall(self.inv.pushItems, peripheral.getName(target.inv), self.slot, cap, target.slot) + + if not result then + return false, xfer + end + + self:_modify(-xfer, self) + target:_modify(xfer, self) + + return xfer == count, xfer +end + +local function objEquals(o1, o2) + local objType = type(o1) + if objType ~= type(o2) then + return false + end + + if objType == "table" then + if #o1 ~= #o2 then + return false + end + + for i,v in ipairs(o1) do + if not objEquals(v, o2[i]) then + return false + end + end + + for k,v in pairs(o1) do + if not objEquals(v, o2[k]) then + return false + end + end + + return true + else + return o1 == o2 + end +end + +function ItemStack:__eq(other) + return type(other) == "table" and + self.count == other.count and + self.maxCount == other.maxCount and + self:canTransfer(other) +end + + +-- Determines if two stacks can be transferred to eachother +function ItemStack:canTransfer(stack) + return self.name == stack.name and + self.damage == stack.damage and + self.maxDamage == stack.maxDamage and + objEquals(self.enchantments, stack.enchantments) +end + +local function queryField(query, field) + local queryType = type(query) + if queryType == "nil" then + return true + elseif queryType == "function" then + return query(field) + end + + return objEquals(query, field) +end + +--- Matches a query object/function(s) +--- @param query table | function +function ItemStack:matches(query) + if query == nil then + return true + elseif type(query) == "function" then + return query(self) + end + + return queryField(query.name, self.name) and + queryField(query.damage, self.damage) and + queryField(query.count, self.count) and + queryField(query.maxDamage, self.maxDamage) and + queryField(query.enchantments, self.enchantments) and + queryField(query.maxCount, self.maxCount) +end + +return ItemStack \ No newline at end of file