Compare commits

...

6 Commits

6 changed files with 933 additions and 25 deletions

View File

@@ -19,8 +19,10 @@ local function _path(rel) return fs.combine(_baseDir, rel) end
-- Structured logging & shared UI helpers
-------------------------------------------------
local log = dofile(_path("lib/log.lua"))
local ui = dofile(_path("lib/ui.lua"))
local log = dofile(_path("lib/log.lua"))
local ui = dofile(_path("lib/ui.lua"))
local itemDB = dofile(_path("lib/itemDB.lua"))
itemDB.init(_path(".item_names.db"))
-------------------------------------------------
-- Load modules (factory pattern → shared context)
@@ -48,6 +50,12 @@ ctx.ops = ops
local display = dofile(_path("manager/display.lua"))(ctx)
ctx.display = display
-- Recursive crafting engine
local craftEngine = dofile(_path("lib/craft.lua"))
craftEngine.init(cfg.recipeBook, ops.getItemTotal)
ctx.craftEngine = craftEngine
ctx.itemDB = itemDB
-- Convenience aliases
local cache = state.cache
local activity = state.activity
@@ -196,7 +204,11 @@ local function main()
for k in pairs(cfg.SMELTABLE) do
if not state.disabledRecipes[k] then enabledCount = enabledCount + 1 end
end
log.info("INIT", "%d/%d recipes enabled", enabledCount, totalRecipeCount)
log.info("INIT", "%d/%d smelting recipes enabled", enabledCount, totalRecipeCount)
do
local cc, sc = cfg.recipeBook.count()
log.info("INIT", "Recipe book: %d crafting, %d smelting", cc, sc)
end
-- Compost peripherals
if peripheral.isPresent(cfg.COMPOST_DROPPER) then
@@ -304,6 +316,8 @@ local function main()
sleep(cfg.SCAN_INTERVAL)
pcall(ops.refreshCache)
pcall(ops.checkAlerts)
pcall(function() itemDB.flush() end)
pcall(function() cfg.recipeBook.flush() end)
state.needsRedraw = true
state.smelterNeedsRedraw = true
end
@@ -442,7 +456,17 @@ local function main()
end
end,
-- Task 12: Network order/command listener
-- Task 12: Supply chest (builder / manifest-based stocking)
function()
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then return end
log.info("SUPPLY", "Stocking %s with %d item types", cfg.SUPPLY_CHEST, #cfg.SUPPLY_MANIFEST)
while true do
pcall(ops.supplyChest)
sleep(cfg.SUPPLY_INTERVAL)
end
end,
-- Task 13: Network order/command listener
function()
if not ctx.networkModem then return end
while true do
@@ -589,6 +613,56 @@ local function main()
state.smelterNeedsRedraw = true
state.needsRedraw = true
pcall(broadcastState)
elseif message.type == "recursive_craft" and message.itemName and message.count then
log.info("NET", "Recursive craft: %s x%d", message.itemName, message.count)
local pok, ok, craftErr = pcall(ops.recursiveCraft, message.itemName, message.count)
if not pok then
log.error("NET", "recursiveCraft crashed: %s", tostring(ok))
craftErr = tostring(ok)
ok = false
end
pcall(function()
ctx.networkModem.transmit(replyChannel, cfg.ORDER_CHANNEL, {
type = "recursive_craft_result",
commandId = message.commandId,
success = ok,
error = craftErr,
})
end)
state.smelterNeedsRedraw = true
state.needsRedraw = true
pcall(broadcastState)
elseif message.type == "learn_crafting_recipe" and message.output and message.count and message.grid then
cfg.recipeBook.learnCraftingRecipe(message.output, message.count, message.grid)
cfg.refreshRecipes()
cfg.recipeBook.flush()
log.info("NET", "Learned crafting recipe: %s", message.output)
state.configDirty = true
state.bumpStateVersion()
pcall(broadcastState)
elseif message.type == "learn_smelting_recipe" and message.input and message.result then
cfg.recipeBook.learnSmeltingRecipe(message.input, message.result, message.furnaces)
cfg.refreshRecipes()
cfg.recipeBook.flush()
log.info("NET", "Learned smelting recipe: %s -> %s", message.input, message.result)
state.configDirty = true
state.bumpStateVersion()
pcall(broadcastState)
elseif message.type == "forget_recipe" and message.recipe then
local forgot = cfg.recipeBook.forgetCraftingRecipe(message.recipe) or
cfg.recipeBook.forgetSmeltingRecipe(message.recipe)
if forgot then
cfg.refreshRecipes()
cfg.recipeBook.flush()
log.info("NET", "Forgot recipe: %s", message.recipe)
state.configDirty = true
state.bumpStateVersion()
end
pcall(broadcastState)
end
end) -- pcall handler

325
lib/craft.lua Normal file
View File

@@ -0,0 +1,325 @@
-- lib/craft.lua — Recursive crafting engine
-- Adapted from opus-apps milo/apis/craft2.lua for Inventory Manager.
-- Supports multi-step crafting chains with cycle detection.
--
-- Usage:
-- local craft = dofile("lib/craft.lua")
-- craft.init(recipeBook, ops.getItemTotal)
-- local ok, err = craft.executeChain("minecraft:chest", 1, ctx)
local craft = {}
local recipeBook = nil -- lib/recipeBook instance
local getItemTotal = nil -- function(itemName) -> number
--- Initialize the crafting engine.
-- @param rb recipeBook instance (lib/recipeBook.lua)
-- @param getTotal function(itemName) returning stock count
function craft.init(rb, getTotal)
recipeBook = rb
getItemTotal = getTotal
end
-------------------------------------------------
-- Ingredient analysis
-------------------------------------------------
--- Sum ingredients across a recipe grid.
-- @return { [itemName] = count }
function craft.sumIngredients(recipe)
return recipeBook.getIngredients(recipe)
end
--- Recursively determine how many items can be crafted.
-- Follows sub-recipe chains with cycle detection.
-- @param recipe crafting recipe table
-- @param count desired output quantity
-- @return craftable amount (may be less than count)
function craft.getCraftableAmount(recipe, count)
local path = { [recipe.output] = true }
local function check(r, summed, cnt, p)
local can = 0
for _ = 1, cnt do
for ingr, needed in pairs(craft.sumIngredients(r)) do
local avail = summed[ingr] or getItemTotal(ingr)
-- Try sub-crafting if we don't have enough
if avail < needed then
local sub = recipeBook.getCraftingRecipe(ingr)
if sub and not p[ingr] then
local sp = {}
for k, v in pairs(p) do sp[k] = v end
sp[sub.output] = true
avail = avail + check(sub, summed, needed - avail, sp)
end
end
if avail < needed then return can end
summed[ingr] = avail - needed
end
can = can + r.count
end
return can
end
return check(recipe, {}, math.ceil(count / recipe.count), path)
end
--- Build a full resource list for a recipe (what's needed, available, missing).
-- Recursively expands sub-recipes.
-- @param recipe crafting recipe table
-- @param count desired output quantity
-- @return { [itemName] = { name, have, total, used, need, craftable } }
function craft.getResourceList(recipe, count)
local summed = {}
local function sum(r, cnt, path)
for ingr, per in pairs(craft.sumIngredients(r)) do
local need = per * cnt
if not summed[ingr] then
summed[ingr] = {
name = ingr,
have = getItemTotal(ingr),
total = 0,
used = 0,
need = 0,
craftable = recipeBook.isCraftable(ingr),
}
end
local e = summed[ingr]
e.total = e.total + need
local canUse = math.min(e.have, need)
e.used = e.used + canUse
e.have = e.have - canUse
local short = need - canUse
if short > 0 then
local sub = recipeBook.getCraftingRecipe(ingr)
if sub and not path[ingr] then
local p = {}
for k, v in pairs(path) do p[k] = v end
p[sub.output] = true
sum(sub, math.ceil(short / sub.count), p)
else
e.need = e.need + short
end
end
end
end
sum(recipe, math.ceil(count / recipe.count), { [recipe.output] = true })
return summed
end
-------------------------------------------------
-- Crafting chain planning
-------------------------------------------------
--- Plan a crafting chain (bottom-up order: sub-ingredients first).
-- @param targetItem string — output item name
-- @param count number — desired quantity
-- @return array of steps { recipe, count (batches), output, outputCount } or nil, error
function craft.planChain(targetItem, count)
local recipe = recipeBook.getCraftingRecipe(targetItem)
if not recipe then return nil, "No recipe for " .. targetItem end
local steps = {}
local visited = {}
local function plan(r, cnt, depth)
if depth > 20 then return false, "Recipe chain too deep (cycle?)" end
local batches = math.ceil(cnt / r.count)
-- Plan sub-ingredients first (depth-first)
for ingr, per in pairs(craft.sumIngredients(r)) do
local need = per * batches
local have = getItemTotal(ingr)
if have < need and not visited[ingr] then
local sub = recipeBook.getCraftingRecipe(ingr)
if sub then
local ok, err = plan(sub, need - have, depth + 1)
if not ok then return false, err end
end
end
end
-- Add this step after all sub-steps
if not visited[r.output] then
visited[r.output] = true
table.insert(steps, {
recipe = r,
count = batches,
output = r.output,
outputCount = batches * r.count,
})
end
return true
end
local ok, err = plan(recipe, count, 0)
if not ok then return nil, err end
return steps
end
-------------------------------------------------
-- Craft execution (remote turtle protocol)
-------------------------------------------------
--- Execute a single craft batch via remote turtle.
-- Uses the existing IM craft_request/craft_result modem protocol.
-- @param recipe crafting recipe table
-- @param ctx context table with ops, cfg, state, log, craftTurtleName, networkModem
-- @return success, error_message
function craft.executeSingleCraft(recipe, ctx)
local ops = ctx.ops
local cfg = ctx.cfg
local st = ctx.state
if not ctx.craftTurtleName then return false, "No turtle" end
if not ctx.networkModem then return false, "No modem" end
if not peripheral.isPresent(ctx.craftTurtleName) then return false, "Turtle offline" end
local chests = ops.getChests()
local slotMap = {}
local reserved = {}
-- Map each grid position to a chest slot
for gridPos = 1, 9 do
local itemName = recipe.grid[gridPos]
if itemName then
local tSlot = cfg.GRID_TO_SLOT[gridPos]
local found = false
if st.cache.catalogue[itemName] then
for _, src in ipairs(st.cache.catalogue[itemName]) do
local chest = ops.wrapCached(src.chest)
if chest then
for slot, si in pairs(chest.list()) do
local key = src.chest .. ":" .. slot
if si.name == itemName and not reserved[key] then
slotMap[tostring(tSlot)] = {
chestName = src.chest,
chestSlot = slot,
itemName = itemName,
count = 1,
}
reserved[key] = true
found = true
break
end
end
end
if found then break end
end
end
if not found then
return false, "Missing ingredient: " .. itemName
end
end
end
-- Send craft request to turtle
ctx.networkModem.transmit(cfg.CRAFT_CHANNEL, cfg.CRAFT_REPLY_CHANNEL, {
type = "craft_request",
output = recipe.output,
slots = slotMap,
returnChests = chests,
})
-- Optimistically update cache
for _, info in pairs(slotMap) do
st.adjustCache(info.itemName, info.chestName, -info.count)
end
-- Wait for turtle reply
local deadline = os.clock() + cfg.CRAFT_TIMEOUT
local result
local buffered = {}
while os.clock() < deadline do
local timerId = os.startTimer(math.max(0.1, deadline - os.clock()))
local event, p1, p2, p3, p4, p5 = os.pullEvent()
if event == "modem_message" then
if p2 == cfg.CRAFT_REPLY_CHANNEL and type(p4) == "table" and p4.type == "craft_result" then
result = p4
break
elseif p2 == cfg.ORDER_CHANNEL then
table.insert(buffered, { event, p1, p2, p3, p4, p5 })
end
end
end
-- Re-queue any buffered messages
for _, msg in ipairs(buffered) do
os.queueEvent(table.unpack(msg))
end
if not result then
return false, "Turtle timeout"
end
if result.success then
local totalOutput = result.totalOutput or recipe.count
if result.results then
for _, r in ipairs(result.results) do
if #chests > 0 then
st.adjustCache(r.name, chests[1], r.count)
end
end
elseif #chests > 0 then
st.adjustCache(recipe.output, chests[1], totalOutput)
end
return true
else
-- Restore cache on failure
for _, info in pairs(slotMap) do
st.adjustCache(info.itemName, info.chestName, info.count)
end
return false, result.error or "Craft failed"
end
end
--- Execute a full crafting chain (all steps, bottom-up).
-- @param targetItem string — desired output item name
-- @param count number — desired quantity
-- @param ctx context table
-- @return success, error_message
function craft.executeChain(targetItem, count, ctx)
local steps, err = craft.planChain(targetItem, count)
if not steps then return false, err end
ctx.log.info("CRAFT", "Chain: %d steps for %s x%d", #steps, targetItem, count)
for i, step in ipairs(steps) do
ctx.log.info("CRAFT", "Step %d/%d: %s x%d (%d batches)",
i, #steps, step.output, step.outputCount, step.count)
ctx.state.activity.crafting = true
ctx.state.needsRedraw = true
ctx.state.smelterNeedsRedraw = true
for batch = 1, step.count do
local ok, batchErr = craft.executeSingleCraft(step.recipe, ctx)
if not ok then
ctx.state.activity.crafting = false
ctx.state.needsRedraw = true
ctx.log.error("CRAFT", "Chain failed at step %d batch %d: %s", i, batch, batchErr)
return false, string.format("Step %d/%d failed: %s", i, #steps, batchErr)
end
-- Brief pause between batches to let turtle finish
if batch < step.count then os.sleep(0.3) end
end
ctx.log.info("CRAFT", "Step %d/%d complete: %s x%d", i, #steps, step.output, step.outputCount)
-- Pause between steps to let items settle in storage
if i < #steps then os.sleep(0.5) end
end
ctx.state.activity.crafting = false
ctx.state.needsRedraw = true
ctx.state.smelterNeedsRedraw = true
ctx.log.info("CRAFT", "Chain complete: %s x%d", targetItem, count)
return true
end
return craft

114
lib/itemDB.lua Normal file
View File

@@ -0,0 +1,114 @@
-- lib/itemDB.lua — Item display name database
-- Learns display names from getItemDetail() and persists to disk.
-- Adapted from opus-apps core.itemDB for Inventory Manager.
--
-- Usage:
-- local itemDB = dofile("lib/itemDB.lua")
-- itemDB.init(".item_names.db")
-- itemDB.learn("minecraft:diamond", "Diamond")
-- print(itemDB.getName("minecraft:diamond")) --> "Diamond"
local itemDB = {}
local nameData = {}
local DATA_FILE = nil
local dirty = false
-------------------------------------------------
-- Initialization and persistence
-------------------------------------------------
function itemDB.init(dataFile)
DATA_FILE = dataFile
itemDB.load()
end
function itemDB.load()
if not DATA_FILE or not fs.exists(DATA_FILE) then return end
pcall(function()
local f = fs.open(DATA_FILE, "r")
local raw = f.readAll()
f.close()
local data = textutils.unserialise(raw)
if type(data) == "table" then
nameData = data
end
end)
end
function itemDB.flush()
if not dirty or not DATA_FILE then return end
pcall(function()
local f = fs.open(DATA_FILE, "w")
f.write(textutils.serialise(nameData))
f.close()
end)
dirty = false
end
-------------------------------------------------
-- Name resolution
-------------------------------------------------
--- Get a human-readable display name for an item.
-- Falls back to generating a name from the item ID.
-- @param itemName string or table with .name field
-- @return string
function itemDB.getName(itemName)
if type(itemName) == "table" then itemName = itemName.name end
if not itemName then return "?" end
if nameData[itemName] then return nameData[itemName] end
-- Generate readable name from item ID (e.g. "minecraft:iron_ingot" -> "Iron Ingot")
local short = itemName:gsub("^[%w_]+:", "")
return short:gsub("_", " "):gsub("(%a)([%w_']*)", function(first, rest)
return first:upper() .. rest:lower()
end)
end
--- Learn a display name for an item.
-- @param itemName string or table with .name and optional .displayName
-- @param displayName string (optional if itemName is a table)
function itemDB.learn(itemName, displayName)
if type(itemName) == "table" then
displayName = displayName or itemName.displayName
itemName = itemName.name
end
if itemName and displayName and displayName ~= "" then
if nameData[itemName] ~= displayName then
nameData[itemName] = displayName
dirty = true
end
end
end
--- Learn from a getItemDetail() result table.
-- @param detail table with .name and .displayName fields
function itemDB.learnFromDetail(detail)
if detail and detail.name and detail.displayName then
itemDB.learn(detail.name, detail.displayName)
end
end
-------------------------------------------------
-- Queries
-------------------------------------------------
--- Check if an item's display name has been learned.
function itemDB.isKnown(itemName)
if type(itemName) == "table" then itemName = itemName.name end
return nameData[itemName] ~= nil
end
--- Get all known name mappings.
function itemDB.getAllNames()
return nameData
end
--- Get count of known items.
function itemDB.count()
local n = 0
for _ in pairs(nameData) do n = n + 1 end
return n
end
return itemDB

248
lib/recipeBook.lua Normal file
View File

@@ -0,0 +1,248 @@
-- lib/recipeBook.lua — Unified recipe database with learning support
-- Loads built-in recipes from data files + user-learned recipes from disk.
-- Compatible with Prominence II Hasturian Era modpack (or any modded setup).
--
-- Usage:
-- local recipeBook = dofile("lib/recipeBook.lua")
-- recipeBook.init(".recipes.db")
-- recipeBook.loadLegacyCrafting(dofile("data/craftable.lua"))
-- recipeBook.loadLegacySmelting(dofile("data/smeltable.lua"))
-- recipeBook.learnCraftingRecipe("mod:item", 4, { ... })
-- recipeBook.flush()
local recipeBook = {}
local recipes = {
crafting = {}, -- keyed by output item name
smelting = {}, -- keyed by input item name
}
local RECIPE_FILE = nil
local dirty = false
-------------------------------------------------
-- Initialization and persistence
-------------------------------------------------
function recipeBook.init(recipeFile)
RECIPE_FILE = recipeFile
recipeBook.loadUserRecipes()
end
--- Load legacy crafting recipes from data/craftable.lua format.
-- Does not overwrite recipes already loaded (user-learned take priority).
function recipeBook.loadLegacyCrafting(craftableArray)
for _, recipe in ipairs(craftableArray) do
if not recipes.crafting[recipe.output] then
recipes.crafting[recipe.output] = {
output = recipe.output,
count = recipe.count,
grid = recipe.grid,
source = "builtin",
}
end
end
end
--- Load legacy smelting recipes from data/smeltable.lua format.
-- Does not overwrite recipes already loaded (user-learned take priority).
function recipeBook.loadLegacySmelting(smeltableTable)
for input, recipe in pairs(smeltableTable) do
if not recipes.smelting[input] then
recipes.smelting[input] = {
input = input,
result = recipe.result,
furnaces = recipe.furnaces,
source = "builtin",
}
end
end
end
--- Load user-learned recipes from disk.
function recipeBook.loadUserRecipes()
if not RECIPE_FILE or not fs.exists(RECIPE_FILE) then return end
pcall(function()
local f = fs.open(RECIPE_FILE, "r")
local raw = f.readAll()
f.close()
local data = textutils.unserialise(raw)
if type(data) == "table" then
for output, recipe in pairs(data.crafting or {}) do
recipe.source = "learned"
recipe.output = output
recipes.crafting[output] = recipe
end
for input, recipe in pairs(data.smelting or {}) do
recipe.source = "learned"
recipe.input = input
recipes.smelting[input] = recipe
end
end
end)
end
--- Save user-learned recipes to disk.
function recipeBook.flush()
if not dirty or not RECIPE_FILE then return end
pcall(function()
local uc, us = {}, {}
for output, r in pairs(recipes.crafting) do
if r.source == "learned" then
uc[output] = { output = r.output, count = r.count, grid = r.grid }
end
end
for input, r in pairs(recipes.smelting) do
if r.source == "learned" then
us[input] = { result = r.result, furnaces = r.furnaces }
end
end
local f = fs.open(RECIPE_FILE, "w")
f.write(textutils.serialise({ crafting = uc, smelting = us }))
f.close()
end)
dirty = false
end
-------------------------------------------------
-- Learning / forgetting recipes
-------------------------------------------------
--- Learn a new crafting recipe (or overwrite an existing one).
-- @param output string — output item name (e.g. "minecraft:stick")
-- @param count number — items produced per craft
-- @param grid table — 9-entry grid array
function recipeBook.learnCraftingRecipe(output, count, grid)
recipes.crafting[output] = {
output = output,
count = count,
grid = grid,
source = "learned",
}
dirty = true
end
--- Learn a new smelting recipe.
-- @param input string — input item name
-- @param result string — output item name
-- @param furnaces table — array of furnace type strings (default: {"minecraft:furnace"})
function recipeBook.learnSmeltingRecipe(input, result, furnaces)
recipes.smelting[input] = {
input = input,
result = result,
furnaces = furnaces or { "minecraft:furnace" },
source = "learned",
}
dirty = true
end
--- Forget a learned crafting recipe (built-in recipes cannot be forgotten).
-- @return true if removed, false if recipe was built-in or not found
function recipeBook.forgetCraftingRecipe(output)
if recipes.crafting[output] and recipes.crafting[output].source == "learned" then
recipes.crafting[output] = nil
dirty = true
return true
end
return false
end
--- Forget a learned smelting recipe.
function recipeBook.forgetSmeltingRecipe(input)
if recipes.smelting[input] and recipes.smelting[input].source == "learned" then
recipes.smelting[input] = nil
dirty = true
return true
end
return false
end
-------------------------------------------------
-- Lookups
-------------------------------------------------
--- Get a crafting recipe by output item name.
function recipeBook.getCraftingRecipe(output)
return recipes.crafting[output]
end
--- Get a smelting recipe by input item name.
function recipeBook.getSmeltingRecipe(input)
return recipes.smelting[input]
end
--- Find any recipe (crafting or smelting) that produces a given item.
-- @return recipe, recipeType ("crafting" or "smelting") or nil
function recipeBook.findRecipeFor(itemName)
if recipes.crafting[itemName] then
return recipes.crafting[itemName], "crafting"
end
for input, recipe in pairs(recipes.smelting) do
if recipe.result == itemName then
return recipe, "smelting"
end
end
return nil
end
-------------------------------------------------
-- Backward-compatible accessors
-------------------------------------------------
--- Get all crafting recipes as an indexed array (compat with cfg.CRAFTABLE).
-- Sorted by output name.
function recipeBook.getCraftingList()
local list = {}
for _, recipe in pairs(recipes.crafting) do
table.insert(list, recipe)
end
table.sort(list, function(a, b) return a.output < b.output end)
return list
end
--- Get all smelting recipes as a keyed table (compat with cfg.SMELTABLE).
function recipeBook.getSmeltingTable()
local result = {}
for input, recipe in pairs(recipes.smelting) do
result[input] = recipe
end
return result
end
-------------------------------------------------
-- Utilities
-------------------------------------------------
--- Get summed ingredients for a crafting recipe.
-- @return table { [itemName] = count }
function recipeBook.getIngredients(recipe)
local ingredients = {}
if recipe and recipe.grid then
for _, item in ipairs(recipe.grid) do
if item then
ingredients[item] = (ingredients[item] or 0) + 1
end
end
end
return ingredients
end
--- Quick craftability check.
function recipeBook.isCraftable(itemName)
return recipes.crafting[itemName] ~= nil
end
--- Quick smeltability check.
function recipeBook.isSmeltable(itemName)
return recipes.smelting[itemName] ~= nil
end
--- Count total recipes.
-- @return craftCount, smeltCount
function recipeBook.count()
local cc, sc = 0, 0
for _ in pairs(recipes.crafting) do cc = cc + 1 end
for _ in pairs(recipes.smelting) do sc = sc + 1 end
return cc, sc
end
return recipeBook

View File

@@ -46,6 +46,17 @@ C.COMPOST_HOPPER = "minecraft:hopper_0"
-- Peripheral
C.PERIPHERAL_CACHE_TTL = 5
-- Parallel scanning
C.PARALLEL_SCAN_CHUNKS = 8
-- Storage priority (higher = preferred; keyed by chest peripheral name)
C.CHEST_PRIORITY = {}
-- Builder / supply manifest
C.SUPPLY_CHEST = "" -- peripheral name of supply chest (empty = disabled)
C.SUPPLY_INTERVAL = 10 -- seconds between supply checks
C.SUPPLY_MANIFEST = {} -- { { name = "mod:item", count = N }, ... }
-- Furnace types
C.FURNACE_TYPES = {
"minecraft:furnace",
@@ -92,6 +103,11 @@ function C.loadConfig()
if cfg.compostReserve then C.COMPOST_RESERVE = cfg.compostReserve end
if cfg.compostDropper then C.COMPOST_DROPPER = cfg.compostDropper end
if cfg.compostHopper then C.COMPOST_HOPPER = cfg.compostHopper end
if cfg.parallelScanChunks then C.PARALLEL_SCAN_CHUNKS = cfg.parallelScanChunks end
if cfg.chestPriority then C.CHEST_PRIORITY = cfg.chestPriority end
if cfg.supplyChest then C.SUPPLY_CHEST = cfg.supplyChest end
if cfg.supplyInterval then C.SUPPLY_INTERVAL = cfg.supplyInterval end
if cfg.supplyManifest then C.SUPPLY_MANIFEST = cfg.supplyManifest end
if cfg.logLevel then log.setLevel(cfg.logLevel) end
log.info("CONFIG", "Loaded from %s", CONFIG_FILE)
end
@@ -100,31 +116,36 @@ end
-- Data tables
-------------------------------------------------
C.SMELTABLE = dofile(_path("data/smeltable.lua"))
C.FUEL_LIST = dofile(_path("data/fuel.lua"))
local _compostData = dofile(_path("data/compostable.lua"))
C.COMPOSTABLE = _compostData.items
C.COMPOST_TRASH = _compostData.trash
C.CRAFTABLE = dofile(_path("data/craftable.lua"))
C.LOW_STOCK_ALERTS = dofile(_path("data/alerts.lua"))
-- Pre-build furnace compatibility sets for O(1) lookup
for _, recipe in pairs(C.SMELTABLE) do
recipe.furnaceSet = {}
for _, ft in ipairs(recipe.furnaces) do
recipe.furnaceSet[ft] = true
end
end
-- Recipe book: merges built-in recipes + user-learned recipes
local recipeBook = dofile(_path("lib/recipeBook.lua"))
recipeBook.init(_path(".recipes.db"))
recipeBook.loadLegacyCrafting(dofile(_path("data/craftable.lua")))
recipeBook.loadLegacySmelting(dofile(_path("data/smeltable.lua")))
C.recipeBook = recipeBook
C.SMELTABLE = recipeBook.getSmeltingTable()
C.CRAFTABLE = recipeBook.getCraftingList()
-- Pre-built smelt candidate lists per furnace type
C.smeltCandidatesByType = {}
do
-- Rebuild furnace/smelt indices from current recipe data
function C.rebuildIndices()
for _, recipe in pairs(C.SMELTABLE) do
recipe.furnaceSet = {}
for _, ft in ipairs(recipe.furnaces) do
recipe.furnaceSet[ft] = true
end
end
C.smeltCandidatesByType = {}
for _, ftype in ipairs(C.FURNACE_TYPES) do
C.smeltCandidatesByType[ftype] = {}
end
for itemName, recipe in pairs(C.SMELTABLE) do
local isFood = recipe.furnaceSet["minecraft:smoker"] or false
for ft, _ in pairs(recipe.furnaceSet) do
for ft in pairs(recipe.furnaceSet) do
table.insert(C.smeltCandidatesByType[ft], { name = itemName, recipe = recipe, food = isFood })
end
end
@@ -136,6 +157,15 @@ do
end
end
-- Refresh recipes from recipeBook and rebuild all indices
function C.refreshRecipes()
C.SMELTABLE = C.recipeBook.getSmeltingTable()
C.CRAFTABLE = C.recipeBook.getCraftingList()
C.rebuildIndices()
end
C.rebuildIndices()
-- Build fuel set for quick lookup
C.FUEL_SET = {}
for _, f in ipairs(C.FUEL_LIST) do C.FUEL_SET[f.name] = true end

View File

@@ -148,13 +148,39 @@ function O.refreshCache(onProgress)
local totalSlots = 0
local usedSlots = 0
for ci, chest in ipairs(chests) do
if onProgress then onProgress(ci, #chests, chest) end
local inv = O.wrapCached(chest)
if inv then
totalSlots = totalSlots + inv.size()
local contents = inv.list()
for slot, item in pairs(contents) do
-- Parallel inventory scanning (chunked)
local CHUNK = cfg.PARALLEL_SCAN_CHUNKS or 8
local scanData = {}
local scanFns = {}
for _, chest in ipairs(chests) do
table.insert(scanFns, function()
local inv = O.wrapCached(chest)
if inv then
scanData[chest] = {
size = inv.size(),
contents = inv.list(),
}
end
end)
end
for i = 1, #scanFns, CHUNK do
local chunk = {}
local chunkEnd = math.min(i + CHUNK - 1, #scanFns)
for j = i, chunkEnd do
chunk[#chunk + 1] = scanFns[j]
end
if onProgress then
onProgress(chunkEnd, #chests, chests[chunkEnd] or "scanning...")
end
parallel.waitForAll(table.unpack(chunk))
end
for _, chest in ipairs(chests) do
local data = scanData[chest]
if data then
totalSlots = totalSlots + data.size
for slot, item in pairs(data.contents) do
usedSlots = usedSlots + 1
if not catalogue[item.name] then
catalogue[item.name] = {}
@@ -293,6 +319,22 @@ end
-- Barrel auto-sort
-------------------------------------------------
--- Get chests sorted by priority (highest first).
-- Falls back to name order if no priority is configured.
function O.getChestsByPriority()
local chests = O.getChests()
local priority = cfg.CHEST_PRIORITY
if not priority or not next(priority) then return chests end
local sorted = { table.unpack(chests) }
table.sort(sorted, function(a, b)
local pa = priority[a] or 0
local pb = priority[b] or 0
if pa ~= pb then return pa > pb end
return a < b
end)
return sorted
end
function O.sortBarrel(barrelOverride)
local barrelTarget = (barrelOverride and barrelOverride ~= "") and barrelOverride or cfg.BARREL_NAME
local barrel = O.wrapCached(barrelTarget)
@@ -305,7 +347,7 @@ function O.sortBarrel(barrelOverride)
state.needsRedraw = true
local catalogue = cache.catalogue
local chests = O.getChests()
local chests = O.getChestsByPriority()
for slot, item in pairs(contents) do
local moved = 0
@@ -751,6 +793,63 @@ function O.checkAlerts()
end
end
-------------------------------------------------
-- Supply chest (builder / manifest-based stocking)
-------------------------------------------------
function O.supplyChest()
if cfg.SUPPLY_CHEST == "" or #cfg.SUPPLY_MANIFEST == 0 then return false end
local supply = O.wrapCached(cfg.SUPPLY_CHEST)
if not supply then return false end
-- Scan what's already in the supply chest
local existing = {}
local contents = supply.list()
if contents then
for _, item in pairs(contents) do
existing[item.name] = (existing[item.name] or 0) + item.count
end
end
local catalogue = cache.catalogue
local didWork = false
for _, manifest in ipairs(cfg.SUPPLY_MANIFEST) do
local have = existing[manifest.name] or 0
local want = manifest.count or 0
local deficit = want - have
if deficit > 0 and catalogue[manifest.name] then
local remaining = deficit
local srcSnapshot = { table.unpack(catalogue[manifest.name]) }
for _, source in ipairs(srcSnapshot) do
if source.total > 0 then
local chest = O.wrapCached(source.chest)
if chest then
for slot, slotItem in pairs(chest.list()) do
if slotItem.name == manifest.name then
local toMove = math.min(slotItem.count, remaining)
local n = chest.pushItems(cfg.SUPPLY_CHEST, slot, toMove)
if n and n > 0 then
state.adjustCache(manifest.name, source.chest, -n)
remaining = remaining - n
didWork = true
log.info("SUPPLY", "%s x%d -> %s", manifest.name, n, cfg.SUPPLY_CHEST)
if remaining <= 0 then break end
end
end
end
end
end
if remaining <= 0 then break end
end
end
end
return didWork
end
-------------------------------------------------
-- Order / Dispense
-------------------------------------------------
@@ -1012,6 +1111,24 @@ function O.craftItem(recipeIdx)
end
end
-------------------------------------------------
-- Recursive crafting (multi-step chains)
-------------------------------------------------
function O.recursiveCraft(outputName, count)
if not ctx.craftEngine then
return false, "Craft engine not initialized"
end
activity.crafting = true
state.needsRedraw = true
state.smelterNeedsRedraw = true
local ok, err = ctx.craftEngine.executeChain(outputName, count, ctx)
activity.crafting = false
state.needsRedraw = true
state.smelterNeedsRedraw = true
return ok, err
end
-------------------------------------------------
-- Smelting recipe persistence
-------------------------------------------------