Files
Inventory-Manager-CC/inventoryClient.lua

540 lines
19 KiB
Lua

-- Inventory Client: Display-only dashboard
-- Connects to the master inventoryManager via wired modem.
-- Shows the same dashboard but runs NO automation tasks.
-- Orders and smelter controls are sent to the master.
--
-- Reuses the same Opus UI display module (manager/display.lua)
-- as the server, with an ops shim that sends network commands.
-------------------------------------------------
-- Default configuration (overridden by .client_config)
-------------------------------------------------
local BROADCAST_CHANNEL = 4200 -- master sends state on this channel
local ORDER_CHANNEL = 4201 -- master listens for commands here
local CLIENT_CHANNEL = 4202 -- client listens for replies here
local MONITOR_SIDE = "left"
local SMELTER_MONITOR_SIDE = "top"
-- Remote station: set these to use a different dropper/barrel than the master.
-- Leave empty ("") to use the master's default dropper/barrel.
local CLIENT_DROPPER_NAME = "" -- e.g. "minecraft:dropper_5"
local CLIENT_BARREL_NAME = "" -- e.g. "minecraft:barrel_3"
local DROPPER_ANNOUNCE_INTERVAL = 30 -- seconds between dropper announcements
-------------------------------------------------
-- Load config from file if present
-------------------------------------------------
local _baseDir = fs.getDir(shell.getRunningProgram())
local function _path(rel) return fs.combine(_baseDir, rel) end
-- Persistent config path: survives Opus package updates
local _PERSIST_DIR = "usr/config/inventory-manager"
local function _configPath(rel)
if fs.isDir(_PERSIST_DIR) or fs.isDir("packages/inventory-manager") then
if not fs.isDir(_PERSIST_DIR) then fs.makeDir(_PERSIST_DIR) end
return fs.combine(_PERSIST_DIR, rel)
end
return _path(rel)
end
-- Override dofile to load modules into our _ENV so they inherit
-- Opus's require/package (CC:Tweaked dofile uses _G instead).
local _ccDofile = dofile
local function dofile(path) -- luacheck: ignore
local fn, err = loadfile(path, nil, _ENV)
if fn then return fn()
else error(err, 2) end
end
local CLIENT_CONFIG_FILE = _configPath(".client_config")
-------------------------------------------------
-- Modules
-------------------------------------------------
local log = dofile(_path("lib/log.lua"))
local function loadConfig()
if not fs.exists(CLIENT_CONFIG_FILE) then return end
local f = fs.open(CLIENT_CONFIG_FILE, "r")
local data = f.readAll()
f.close()
local ok, cfg = pcall(textutils.unserialiseJSON, data)
if not ok or not cfg then
log.warn("CONFIG", "Failed to parse %s", CLIENT_CONFIG_FILE)
return
end
-- Channels
if cfg.broadcastChannel then BROADCAST_CHANNEL = cfg.broadcastChannel end
if cfg.orderChannel then ORDER_CHANNEL = cfg.orderChannel end
if cfg.clientChannel then CLIENT_CHANNEL = cfg.clientChannel end
-- Peripherals
if cfg.monitorSide then MONITOR_SIDE = cfg.monitorSide end
if cfg.smelterMonitorSide then SMELTER_MONITOR_SIDE = cfg.smelterMonitorSide end
if cfg.dropperName then CLIENT_DROPPER_NAME = cfg.dropperName end
if cfg.barrelName then CLIENT_BARREL_NAME = cfg.barrelName end
-- Timing
if cfg.dropperAnnounceInterval then DROPPER_ANNOUNCE_INTERVAL = cfg.dropperAnnounceInterval end
if cfg.logLevel then log.setLevel(cfg.logLevel) end
log.info("CONFIG", "Loaded from %s", CLIENT_CONFIG_FILE)
end
loadConfig()
-------------------------------------------------
-- Network modem
-------------------------------------------------
local networkModem = nil
-------------------------------------------------
-- Command ID generation (idempotency)
-------------------------------------------------
local cmdCounter = 0
local function newCommandId()
cmdCounter = cmdCounter + 1
return string.format("client_%d_%d_%d", os.getComputerID(), os.epoch("utc"), cmdCounter)
end
-------------------------------------------------
-- Send command to master
-------------------------------------------------
local function sendToMaster(message)
if not networkModem then return end
if not message.commandId then
message.commandId = newCommandId()
end
networkModem.transmit(ORDER_CHANNEL, CLIENT_CHANNEL, message)
end
-------------------------------------------------
-- State — mirrors the manager's state module interface
-- so display.lua can read from it transparently.
-------------------------------------------------
local state = {}
state.cache = {
itemList = {},
itemListDirty = false,
grandTotal = 0,
chestCount = 0,
totalSlots = 0,
usedSlots = 0,
freeSlots = 0,
usedRatio = 0,
dropperOk = false,
barrelOk = false,
furnaceCount = 0,
furnaceStatus = {},
}
state.activity = {
sorting = false,
dispensing = false,
scanning = false,
smelting = false,
defragging = false,
composting = false,
crafting = false,
}
state.needsRedraw = true
state.smelterNeedsRedraw = true
state.statusMessage = ""
state.statusColor = colors.white
state.statusTimer = 0
state.activeAlerts = {}
state.smeltingPaused = false
state.disabledRecipes = {}
-- Client receives itemList pre-built from master; no-op here.
function state.ensureItemList() end
-------------------------------------------------
-- Config — mirrors cfg interface for display.lua
-------------------------------------------------
local cfg = {
MONITOR_SIDE = MONITOR_SIDE,
SMELTER_MONITOR_SIDE = SMELTER_MONITOR_SIDE,
SMELTABLE = {}, -- populated from master broadcast
CRAFTABLE = {}, -- populated from master broadcast
}
-------------------------------------------------
-- Ops shim — sends commands to master instead
-- of executing locally. Exposes the same API
-- that display.lua calls on ctx.ops.
-------------------------------------------------
local ops = {}
--- Get stock total for an item from the received itemList.
local function getItemTotal(itemName)
for _, item in ipairs(state.cache.itemList) do
if item.name == itemName then return item.total end
end
return 0
end
function ops.getRecipeIngredients(recipe)
local ingredients = {}
for _, item in ipairs(recipe.grid) do
if item then
ingredients[item] = (ingredients[item] or 0) + 1
end
end
return ingredients
end
function ops.canCraftRecipe(recipe)
local ingredients = ops.getRecipeIngredients(recipe)
for itemName, needed in pairs(ingredients) do
if (getItemTotal(itemName) or 0) < needed then return false end
end
return true
end
function ops.maxCraftBatches(recipe)
local ingredients = ops.getRecipeIngredients(recipe)
local minBatches = math.huge
for itemName, needed in pairs(ingredients) do
local batches = math.floor((getItemTotal(itemName) or 0) / needed)
if batches < minBatches then minBatches = batches end
end
if minBatches == math.huge then return 0 end
return minBatches
end
function ops.getMissingIngredients(recipe)
local ingredients = ops.getRecipeIngredients(recipe)
local missing = {}
for itemName, needed in pairs(ingredients) do
local have = getItemTotal(itemName) or 0
if have < needed then
table.insert(missing, { name = itemName, have = have, need = needed })
end
end
return missing
end
function ops.orderItem(itemName, amount)
sendToMaster({
type = "order",
itemName = itemName,
amount = amount,
dropperName = CLIENT_DROPPER_NAME ~= "" and CLIENT_DROPPER_NAME or nil,
})
log.info("ORDER", "Sent to master: %s x%d", itemName, amount)
end
function ops.saveDisabledRecipes()
-- Sync full smelting state to master so it persists and applies.
sendToMaster({
type = "sync_disabled_recipes",
disabledRecipes = state.disabledRecipes,
smeltingPaused = state.smeltingPaused,
})
end
function ops.craftItem(recipeIdx)
sendToMaster({ type = "craft", recipeIdx = recipeIdx })
local recipe = cfg.CRAFTABLE[recipeIdx]
if recipe then
log.info("CRAFT", "Craft request sent: %s", recipe.output)
end
-- Return "pending" — the actual result arrives asynchronously
return true, "Sent to master"
end
-------------------------------------------------
-- Connected flag (shown as "waiting" screen)
-------------------------------------------------
local connected = false
local craftTurtleOk = false
-------------------------------------------------
-- Build context for display.lua (same interface
-- that the manager passes to it).
-------------------------------------------------
local ctx = {
cfg = cfg,
state = state,
log = log,
ops = ops,
craftTurtleName = nil, -- updated from master broadcasts
}
-- Load the shared Opus UI display module — same one the manager uses.
local D = dofile(_path("manager/display.lua"))(ctx)
-------------------------------------------------
-- Client dropper discovery
-------------------------------------------------
local function discoverLocalDroppers()
local droppers = {}
-- Check configured dropper first
if CLIENT_DROPPER_NAME ~= "" then
local exists = peripheral.wrap(CLIENT_DROPPER_NAME) ~= nil
if exists then
table.insert(droppers, { name = CLIENT_DROPPER_NAME, isDefault = false, clientId = os.getComputerID() })
end
end
-- Also discover any other droppers on client's local network
for _, name in ipairs(peripheral.getNames()) do
local isDropper = name:match("^minecraft:dropper_")
if not isDropper and peripheral.hasType then
isDropper = peripheral.hasType(name, "minecraft:dropper")
end
if not isDropper then
isDropper = peripheral.getType(name) == "minecraft:dropper"
end
if isDropper then
-- Avoid duplicates with configured dropper
local isDuplicate = false
for _, d in ipairs(droppers) do
if d.name == name then isDuplicate = true; break end
end
if not isDuplicate then
table.insert(droppers, { name = name, isDefault = false, clientId = os.getComputerID() })
end
end
end
return droppers
end
local function announceDroppers()
local droppers = discoverLocalDroppers()
if #droppers > 0 then
sendToMaster({
type = "register_droppers",
clientId = os.getComputerID(),
droppers = droppers,
})
end
end
-------------------------------------------------
-- Main
-------------------------------------------------
local function main()
print("=================================")
print(" Inventory Client (Display Only)")
print("=================================")
print("")
if D.setupMonitor() then
log.info("INIT", "Monitor: %s", D.monName or MONITOR_SIDE)
else
log.warn("INIT", "No monitor on %s", MONITOR_SIDE)
end
if D.setupSmelterMonitor() then
log.info("INIT", "Smelter monitor: %s", D.smelterMonName or SMELTER_MONITOR_SIDE)
else
log.warn("INIT", "No smelter monitor on %s", SMELTER_MONITOR_SIDE)
end
-- Find modem
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" then
networkModem = peripheral.wrap(name)
networkModem.open(BROADCAST_CHANNEL)
networkModem.open(CLIENT_CHANNEL)
log.info("INIT", "Modem: %s", name)
break
end
end
if not networkModem then
log.error("INIT", "No modem found! Cannot receive data from master.")
print(" Attach a wired modem and restart.")
return
end
print("")
print("Listening for master broadcasts on channel " .. BROADCAST_CHANNEL)
print("Console shows log. Use the monitors to interact.")
print("")
-- Draw initial "waiting" screen
state.needsRedraw = true
state.smelterNeedsRedraw = true
parallel.waitForAny(
-- Task 1: Modem receiver (state updates + order replies)
function()
while true do
local event, side, channel, replyChannel, message, distance = os.pullEvent("modem_message")
if channel == BROADCAST_CHANNEL and type(message) == "table" and message.type == "state" then
-- Update all state from master
local c = state.cache
if message.cache then
c.itemList = message.cache.itemList or c.itemList
c.grandTotal = message.cache.grandTotal ~= nil and message.cache.grandTotal or c.grandTotal
c.chestCount = message.cache.chestCount ~= nil and message.cache.chestCount or c.chestCount
c.totalSlots = message.cache.totalSlots ~= nil and message.cache.totalSlots or c.totalSlots
c.usedSlots = message.cache.usedSlots ~= nil and message.cache.usedSlots or c.usedSlots
c.freeSlots = message.cache.freeSlots ~= nil and message.cache.freeSlots or c.freeSlots
c.usedRatio = message.cache.usedRatio ~= nil and message.cache.usedRatio or c.usedRatio
c.dropperOk = message.cache.dropperOk
c.barrelOk = message.cache.barrelOk
c.furnaceCount = message.cache.furnaceCount ~= nil and message.cache.furnaceCount or c.furnaceCount
c.furnaceStatus = message.cache.furnaceStatus or c.furnaceStatus
if message.cache.droppers then
c.droppers = message.cache.droppers
end
end
if message.activity then
for k, v in pairs(message.activity) do
state.activity[k] = v
end
end
if message.alerts then
state.activeAlerts = message.alerts
end
if message.smeltingPaused ~= nil then
state.smeltingPaused = message.smeltingPaused
end
if message.disabledRecipes then
state.disabledRecipes = message.disabledRecipes
end
if message.smeltable then
cfg.SMELTABLE = message.smeltable
end
if message.craftable then
cfg.CRAFTABLE = message.craftable
end
if message.craftTurtleOk ~= nil then
craftTurtleOk = message.craftTurtleOk
-- display.lua supports ctx.craftTurtleOk as an override
-- so remote clients don't need local peripheral access.
ctx.craftTurtleOk = craftTurtleOk
end
if not connected then
connected = true
log.info("NET", "Connected to master!")
end
state.needsRedraw = true
state.smelterNeedsRedraw = true
elseif channel == CLIENT_CHANNEL and type(message) == "table" and message.type == "order_result" then
-- Order result from master
state.statusMessage = message.message or ""
state.statusColor = message.color or (message.success and colors.lime or colors.red)
state.statusTimer = 5
state.needsRedraw = true
if message.success then
log.info("ORDER", "%s", state.statusMessage)
else
log.warn("ORDER", "%s", state.statusMessage)
end
elseif channel == CLIENT_CHANNEL and type(message) == "table" and message.type == "craft_result" then
-- Craft result from master
state.statusMessage = message.error or (message.success and "Craft complete" or "Craft failed")
state.statusColor = message.success and colors.lime or colors.red
state.statusTimer = 5
state.smelterNeedsRedraw = true
if message.success then
log.info("CRAFT", "%s", state.statusMessage)
else
log.warn("CRAFT", "%s", state.statusMessage)
end
end
end
end,
-- Task 2: Inventory dashboard redraw
function()
state.needsRedraw = true
while true do
if state.needsRedraw then
state.needsRedraw = false
pcall(D.drawDashboard)
end
if state.statusTimer > 0 then
state.statusTimer = state.statusTimer - 0.1
if state.statusTimer <= 0 then
state.statusTimer = 0
state.needsRedraw = true
end
end
if #state.activeAlerts > 0 then
state.needsRedraw = true
end
sleep(0.1)
end
end,
-- Task 3: Smelter dashboard redraw
function()
state.smelterNeedsRedraw = true
while true do
if state.smelterNeedsRedraw then
state.smelterNeedsRedraw = false
pcall(D.drawSmelterDashboard)
end
sleep(0.1)
end
end,
-- Task 4: Announce local droppers to master periodically
function()
-- Initial announcement after short delay
sleep(3)
pcall(announceDroppers)
while true do
sleep(DROPPER_ANNOUNCE_INTERVAL)
pcall(announceDroppers)
end
end,
-- Task 5: Client barrel auto-sort (sends to master only when barrel has items)
function()
if CLIENT_BARREL_NAME == "" then
while true do sleep(3600) end
end
log.info("INIT", "Client barrel: %s", CLIENT_BARREL_NAME)
while true do
local barrel = peripheral.wrap(CLIENT_BARREL_NAME)
if barrel then
local contents = barrel.list()
if contents and next(contents) then
sendToMaster({ type = "sort_barrel", barrelName = CLIENT_BARREL_NAME })
end
end
sleep(2)
end
end,
-- Task 6: Touch event listener (both monitors)
function()
while true do
local event, side, x, y = os.pullEvent("monitor_touch")
if D.smelterMonName and side == D.smelterMonName then
log.debug("TOUCH", "x=%d y=%d", x, y)
D.handleSmelterTouch(x, y)
else
log.debug("TOUCH", "x=%d y=%d", x, y)
D.handleTouch(x, y)
end
end
end
)
end
main()