Files
Inventory-Manager-CC/inventoryClient.lua

505 lines
18 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 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 ui = dofile(_path("lib/ui.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
-- Delegate recipe helpers to shared lib/ui.lua with local stock lookup.
function ops.getRecipeIngredients(recipe) return ui.getRecipeIngredients(recipe) end
function ops.canCraftRecipe(recipe) return ui.canCraftRecipe(recipe, getItemTotal) end
function ops.maxCraftBatches(recipe) return ui.maxCraftBatches(recipe, getItemTotal) end
function ops.getMissingIngredients(recipe) return ui.getMissingIngredients(recipe, getItemTotal) 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()