Files
ATM10_CC_Codes/create_factory_scheduler.lua
T

1188 lines
52 KiB
Lua

-- ATM10 Create factory scheduler V1
-- Persistent jobs, dependency planning, progress-aware retries and diagnostics.
local VERSION = "1.7.0"
local REFRESH_SECONDS = 1
local CONFIRM_SECONDS = 6
local DRAIN_SECONDS = 60
local WARNING_SECONDS = 15
local MAX_RETRIES = 2
local STATE_FILE = "factory_scheduler_state.txt"
local STATE_TEMP_FILE = STATE_FILE .. ".tmp"
local EVENT_LOG_FILE = "factory_scheduler_log.txt"
local MAX_EVENTS = 5
local MAX_ERRORS = 50
local ME_BUFFER_NAME = "sophisticatedstorage:chest_0"
local ME_TRANSFER_TIMEOUT = 15
-- Add ordinary modules/recipes here. Multi-step chains use a named planner.
local RECIPES = {
crush_asurine = {
label = "64 ASURINE CRUSHEN", station = "crushing", address = "crushing",
input = "create:asurine", inputCount = 64,
outputs = { "alltheores:zinc_clump", "alltheores:zinc_nugget" }, completion = "activity",
},
crush_crimsite = {
label = "64 CRIMSITE CRUSHEN", station = "crushing", address = "crushing",
input = "create:crimsite", inputCount = 64,
outputs = { "alltheores:iron_clump", "minecraft:iron_nugget" }, completion = "activity",
},
crush_netherrack = {
label = "64 CINDER FLOUR", station = "crushing", address = "crushing",
input = "minecraft:netherrack", inputCount = 64,
-- One Cinder Flour is guaranteed per Netherrack; the second output has
-- a 50% chance and is deliberately treated as a harmless bonus.
output = "create:cinder_flour", outputCount = 64, targetCount = 64,
},
strip_oak = {
label = "64 STRIPPED OAK", station = "saw:2", address = "saw:2",
input = "minecraft:oak_log", inputCount = 64,
output = "minecraft:stripped_oak_log", outputCount = 64,
},
planks_oak = {
label = "64 OAK PLANKS", station = "saw:2", address = "saw:2",
input = "minecraft:stripped_oak_log", inputCount = 11,
output = "minecraft:oak_planks", outputCount = 66, targetCount = 64,
},
shafts = {
label = "64 SHAFTS", station = "saw:1", address = "saw:1",
input = "create:andesite_alloy", inputCount = 11,
output = "create:shaft", outputCount = 66, targetCount = 64,
},
mixer_alloy = {
label = "64 ANDESITE ALLOY", station = "mixer:1", address = "mixer:1",
inputs = {
{ item = "minecraft:andesite", count = 64 },
{ item = "alltheores:zinc_nugget", count = 64 },
},
output = "create:andesite_alloy", outputCount = 64, targetCount = 64,
},
mixer_cogwheel = {
label = "64 SMALL COGWHEELS", station = "mixer:1", address = "mixer:1",
inputs = {
{ item = "minecraft:oak_planks", count = 64 },
{ item = "create:shaft", count = 64 },
},
output = "create:cogwheel", outputCount = 64, targetCount = 64,
},
mixer_large_cogwheel = {
label = "64 LARGE COGWHEELS", station = "mixer:2", address = "mixer:2",
inputs = {
{ item = "minecraft:oak_planks", count = 128 },
{ item = "create:shaft", count = 64 },
},
output = "create:large_cogwheel", outputCount = 64, targetCount = 64,
},
casing_andesite = {
label = "64 ANDESITE CASINGS", station = "casing", address = "casing",
inputs = {
{ item = "minecraft:stripped_oak_log", count = 64 },
{ item = "create:andesite_alloy", count = 64 },
},
output = "create:andesite_casing", outputCount = 64, targetCount = 64,
},
casing_copper = {
label = "64 COPPER CASINGS", station = "casing", address = "casing",
inputs = {
{ item = "minecraft:stripped_oak_log", count = 64 },
{ item = "minecraft:copper_ingot", count = 64 },
},
output = "create:copper_casing", outputCount = 64, targetCount = 64,
},
casing_brass = {
label = "64 BRASS CASINGS", station = "casing", address = "casing",
inputs = {
{ item = "minecraft:stripped_oak_log", count = 64 },
{ item = "alltheores:brass_ingot", count = 64 },
},
output = "create:brass_casing", outputCount = 64, targetCount = 64,
},
casing_railway = {
label = "64 RAILWAY CASINGS", station = "casing", address = "casing",
inputs = {
{ item = "create:brass_casing", count = 64 },
{ item = "create:sturdy_sheet", count = 64 },
},
output = "create:railway_casing", outputCount = 64, targetCount = 64,
},
precision_mechanism = {
label = "16 GOLD PLATES -> PRECISION", station = "precisionmechanism", address = "precisionmechanism",
input = "alltheores:gold_plate", inputCount = 16,
outputs = { "create:precision_mechanism" }, completion = "activity",
},
press_gold = {
label = "64 GOLD PLATES", stations = { "presse:1", "presse:2", "presse:3", "presse:4", "presse:5", "presse:6" },
input = "minecraft:gold_ingot", inputCount = 64,
output = "alltheores:gold_plate", outputCount = 64, targetCount = 64,
},
press_iron = {
label = "64 IRON PLATES", stations = { "presse:1", "presse:2", "presse:3", "presse:4", "presse:5", "presse:6" },
input = "minecraft:iron_ingot", inputCount = 64,
output = "alltheores:iron_plate", outputCount = 64, targetCount = 64,
},
press_copper = {
label = "64 COPPER PLATES", stations = { "presse:1", "presse:2", "presse:3", "presse:4", "presse:5", "presse:6" },
input = "minecraft:copper_ingot", inputCount = 64,
output = "alltheores:copper_plate", outputCount = 64, targetCount = 64,
},
press_brass = {
label = "64 BRASS PLATES", stations = { "presse:1", "presse:2", "presse:3", "presse:4", "presse:5", "presse:6" },
input = "alltheores:brass_ingot", inputCount = 64,
output = "alltheores:brass_plate", outputCount = 64, targetCount = 64,
},
press_zinc = {
label = "64 ZINC PLATES", stations = { "presse:1", "presse:2", "presse:3", "presse:4", "presse:5", "presse:6" },
input = "alltheores:zinc_ingot", inputCount = 64,
output = "alltheores:zinc_plate", outputCount = 64, targetCount = 64,
},
press_steel = {
label = "64 STEEL PLATES", stations = { "presse:1", "presse:2", "presse:3", "presse:4", "presse:5", "presse:6" },
input = "alltheores:steel_ingot", inputCount = 64,
output = "alltheores:steel_plate", outputCount = 64, targetCount = 64,
},
press_blaze_cake_base = {
label = "16 BLAZE CAKE BASES", station = "presse:7", address = "presse:7",
inputs = {
{ item = "minecraft:egg", count = 16 },
{ item = "minecraft:sugar", count = 16 },
{ item = "create:cinder_flour", count = 16 },
},
output = "create:blaze_cake_base", outputCount = 16, targetCount = 16,
batchOutput = 16,
-- Deterministic 1:1 output: once every requested Base is back in stock,
-- the dependent Filling job can be released immediately.
completionDrainSeconds = 0,
},
fill_blaze_cake = {
label = "16 BLAZE CAKES", station = "Blazecake", address = "Blazecake",
input = "create:blaze_cake_base", inputCount = 16,
output = "create:blaze_cake", outputCount = 16, targetCount = 16,
-- Eggs and Blaze Cake Bases are produced/routed in safe 16-item batches.
-- A larger AUTO order continues with the next batch as soon as the
-- previous 16 finished Cakes have returned to the stock network.
batchOutput = 16,
},
zinc_nuggets = {
label = "576 ZINC NUGGETS", planner = "zinc_nuggets",
output = "alltheores:zinc_nugget", targetCount = 576,
},
iron_nuggets = {
label = "64 IRON NUGGETS", planner = "iron_nuggets",
output = "minecraft:iron_nugget", targetCount = 64,
},
}
local ACTION_ORDER = {
"crush_asurine", "crush_crimsite", "crush_netherrack", "strip_oak", "planks_oak", "shafts", "zinc_nuggets",
"iron_nuggets",
"mixer_alloy", "mixer_cogwheel", "mixer_large_cogwheel",
"casing_andesite", "casing_copper", "casing_brass", "casing_railway", "precision_mechanism",
"press_gold", "press_iron", "press_copper", "press_brass", "press_zinc", "press_steel",
"press_blaze_cake_base", "fill_blaze_cake",
}
local KEEP_STOCK = {
{ recipeId = "mixer_cogwheel", item = "create:cogwheel", target = 64 },
{ recipeId = "mixer_large_cogwheel", item = "create:large_cogwheel", target = 64 },
{ recipeId = "iron_nuggets", item = "minecraft:iron_nugget", target = 64 },
{ recipeId = "fill_blaze_cake", item = "create:blaze_cake", target = 64 },
}
local METAL_PLANS = {
zinc_nuggets = {
name = "Zinc", source = "create:asurine", sourceName = "Asurine",
clump = "alltheores:zinc_clump", nugget = "alltheores:zinc_nugget",
},
iron_nuggets = {
name = "Iron", source = "create:crimsite", sourceName = "Crimsite",
clump = "alltheores:iron_clump", nugget = "minecraft:iron_nugget",
},
}
local PRODUCER_BY_ITEM = {
["minecraft:stripped_oak_log"] = "strip_oak",
["minecraft:oak_planks"] = "planks_oak",
["create:shaft"] = "shafts",
["create:andesite_alloy"] = "mixer_alloy",
["create:cogwheel"] = "mixer_cogwheel",
["create:large_cogwheel"] = "mixer_large_cogwheel",
["alltheores:zinc_nugget"] = "zinc_nuggets",
["minecraft:iron_nugget"] = "iron_nuggets",
["create:andesite_casing"] = "casing_andesite",
["create:copper_casing"] = "casing_copper",
["create:brass_casing"] = "casing_brass",
["create:railway_casing"] = "casing_railway",
["alltheores:gold_plate"] = "press_gold",
["alltheores:iron_plate"] = "press_iron",
["alltheores:copper_plate"] = "press_copper",
["alltheores:brass_plate"] = "press_brass",
["alltheores:zinc_plate"] = "press_zinc",
["alltheores:steel_plate"] = "press_steel",
["create:cinder_flour"] = "crush_netherrack",
["create:blaze_cake_base"] = "press_blaze_cake_base",
["create:blaze_cake"] = "fill_blaze_cake",
}
local function epoch()
if os.epoch then return math.floor(os.epoch("utc") / 1000) end
return math.floor(os.clock())
end
local function findPeripheral(wantedType)
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == wantedType then return name end
end
end
local tickerName = findPeripheral("Create_StockTicker")
local monitorName = findPeripheral("monitor")
local meBridgeName = findPeripheral("me_bridge")
if not tickerName then error("Kein Create Stock Ticker gefunden", 0) end
if not monitorName then error("Kein Monitor gefunden", 0) end
local monitor = peripheral.wrap(monitorName)
monitor.setTextScale(0.5)
local stock, stockEntries, vaults = {}, {}, {}
local stockPage, stockPageCount = 1, 1
local stockPageButtons, buttons, modeButtons, errorPageButtons, actionPageButtons = {}, {}, {}, {}, {}
local events = {}
local state = { version = 1, nextJobId = 1, mode = "MANUAL", jobs = {}, errors = {} }
local armed, armedUntil = nil, 0
local statusText, statusColor = "Scheduler bereit", colors.lime
local errorPage = 1
local actionPage = 1
-- ATM10/Almost Unified uses the common NeoForge `c:` material tags. Resolve
-- ordinary metal forms by tag so e.g. create:brass_ingot and
-- alltheores:brass_ingot behave as the same ingredient everywhere.
local MATERIAL_FORMS = {
ingot = "ingots", nugget = "nuggets", plate = "plates", dust = "dusts",
gear = "gears", rod = "rods", clump = "clumps",
}
local function materialTag(item)
if type(item) ~= "string" then return nil end
local path = item:match("^[^:]+:(.+)$")
if not path then return nil end
for singular, plural in pairs(MATERIAL_FORMS) do
local material = path:match("^(.+)_" .. singular .. "$")
if material then return "#c:" .. plural .. "/" .. material end
end
end
local function equivalentItem(left, right)
if left == right then return true end
local leftTag, rightTag = materialTag(left), materialTag(right)
return leftTag ~= nil and leftTag == rightTag
end
local function stockCount(item)
local total = 0
for storedItem, count in pairs(stock) do
if equivalentItem(item, storedItem) then total = total + (tonumber(count) or 0) end
end
return total
end
local function stockCandidates(item)
local candidates, alternatives = {}, {}
if (stock[item] or 0) > 0 then candidates[#candidates + 1] = item end
for storedItem, count in pairs(stock) do
if storedItem ~= item and (tonumber(count) or 0) > 0 and equivalentItem(item, storedItem) then
alternatives[#alternatives + 1] = storedItem
end
end
table.sort(alternatives)
for _, storedItem in ipairs(alternatives) do candidates[#candidates + 1] = storedItem end
return candidates
end
local function formatNumber(value)
local text = tostring(math.floor(tonumber(value) or 0))
local sign, digits = text:match("^([%-]?)(%d+)$")
if not digits then return text end
return sign .. digits:reverse():gsub("(%d%d%d)", "%1."):reverse():gsub("^%.", "")
end
local function timeText() return textutils.formatTime(os.time(), true) end
local function prettyItemName(itemId)
local path = tostring(itemId):match("^[^:]+:(.+)$") or tostring(itemId)
path = path:gsub("_", " ")
return (path:gsub("(%a)([%w']*)", function(first, rest) return first:upper() .. rest:lower() end))
end
local function appendFile(path, line)
local file = fs.open(path, "a")
if file then file.writeLine(line) file.close() end
end
local function logEvent(message, color)
local entry = { time = timeText(), text = tostring(message), color = color or colors.lightGray }
events[#events + 1] = entry
while #events > MAX_EVENTS do table.remove(events, 1) end
appendFile(EVENT_LOG_FILE, "[" .. entry.time .. "] " .. entry.text)
end
local function saveState()
local file = fs.open(STATE_TEMP_FILE, "w")
if not file then return false end
file.write(textutils.serialize(state)) file.close()
if fs.exists(STATE_FILE) then fs.delete(STATE_FILE) end
fs.move(STATE_TEMP_FILE, STATE_FILE)
return true
end
local function addError(job, code, detail)
local recipe = job and RECIPES[job.recipeId]
state.errors[#state.errors + 1] = {
time = timeText(), epoch = epoch(), jobId = job and job.id or 0,
label = recipe and recipe.label or "SYSTEM", code = code,
detail = tostring(detail or ""), attempt = job and (job.retries or 0) + 1 or 0,
}
while #state.errors > MAX_ERRORS do table.remove(state.errors, 1) end
errorPage = math.max(1, math.ceil(#state.errors / 3))
saveState()
end
local function loadState()
if not fs.exists(STATE_FILE) then return end
local file = fs.open(STATE_FILE, "r")
if not file then return end
local restored = textutils.unserialize(file.readAll()) file.close()
if type(restored) ~= "table" then return end
state = restored
state.version, state.nextJobId = state.version or 1, state.nextJobId or 1
state.mode, state.jobs, state.errors = state.mode or "MANUAL", state.jobs or {}, state.errors or {}
for _, job in ipairs(state.jobs) do
local hadArrivalCounter = type(job.arrived) == "table"
job.lastObserved, job.baseline, job.arrived = job.lastObserved or {}, job.baseline or {}, job.arrived or {}
job.retries, job.status = job.retries or 0, job.status or "WAITING"
-- One-time migration of jobs created by V1.0.0.
if not hadArrivalCounter and (job.progress or 0) > 0 then
local recipe = RECIPES[job.recipeId]
if recipe and recipe.output then
job.arrived[recipe.output] = job.progress
elseif recipe and recipe.outputs and recipe.outputs[1] then
job.arrived[recipe.outputs[1]] = job.progress
end
end
job.lastProgressAt, job.phaseStartedAt = epoch(), epoch()
end
-- Timers were deliberately rebased above; begin a fresh pause window too.
if state.mode == "OFF" then state.pausedAt = epoch() else state.pausedAt = nil end
end
local function refreshStock()
local ok, result = pcall(peripheral.call, tickerName, "stock", true)
if not ok then statusText, statusColor = "Stock-Ticker nicht erreichbar", colors.red return false end
local nextStock, details = {}, {}
for _, entry in pairs(result or {}) do
if type(entry) == "table" and entry.name then
nextStock[entry.name] = (nextStock[entry.name] or 0) + (tonumber(entry.count) or 0)
details[entry.name] = entry
end
end
stock = nextStock
local list = {}
for id, count in pairs(stock) do
list[#list + 1] = { id = id, count = count, label = details[id].displayName or prettyItemName(id) }
end
table.sort(list, function(a, b)
if a.label:lower() == b.label:lower() then return a.id < b.id end
return a.label:lower() < b.label:lower()
end)
stockEntries = list
return true
end
local function refreshVaults()
local nextVaults = {}
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "create:item_vault" then
local okSize, slotCount = pcall(peripheral.call, name, "size")
local okList, contents = pcall(peripheral.call, name, "list")
if okSize and okList then
slotCount = tonumber(slotCount) or 0
local okLimit, slotLimit = pcall(peripheral.call, name, "getItemLimit", 1)
slotLimit = okLimit and tonumber(slotLimit) or 64
if not slotLimit or slotLimit <= 0 then slotLimit = 64 end
local used = 0
for _, item in pairs(contents or {}) do
if type(item) == "table" then used = used + (tonumber(item.count) or 0) end
end
local capacity = slotCount * slotLimit
nextVaults[#nextVaults + 1] = {
name = name,
slots = slotCount,
used = used,
capacity = capacity,
free = math.max(0, capacity - used),
percent = capacity > 0 and (used * 100 / capacity) or 0,
}
end
end
end
table.sort(nextVaults, function(a, b) return a.name < b.name end)
vaults = nextVaults
end
local function terminal(job)
return job.status == "DONE" or job.status == "FAILED" or job.status == "CANCELLED"
end
local function findJobByRecipe(recipeId)
for _, job in ipairs(state.jobs) do
if not terminal(job) and job.recipeId == recipeId then return job end
end
end
local function findJobById(jobId)
for _, job in ipairs(state.jobs) do
if job.id == jobId then return job end
end
end
local function stationBusy(station, exceptJob)
for _, job in ipairs(state.jobs) do
if job ~= exceptJob and not terminal(job) and job.activeStation == station then return true end
end
return false
end
local function chooseStation(recipe, job)
if recipe.stations then
for _, station in ipairs(recipe.stations) do
if not stationBusy(station, job) then return station end
end
return nil
end
if stationBusy(recipe.station, job) then return nil end
return recipe.station
end
local function produced(job, item)
-- Count real positive arrivals. Unlike a stock difference this never drops
-- when another factory step consumes the output while the job is running.
return math.max(0, (job.arrived and job.arrived[item]) or 0)
end
local function setBaseline(job, item)
if job.baseline[item] == nil then
job.baseline[item], job.lastObserved[item] = stockCount(item), stockCount(item)
job.arrived = job.arrived or {}
job.arrived[item] = job.arrived[item] or 0
end
end
local function request(address, item, count)
local remaining, sentTotal, lastError = math.max(1, math.floor(count)), 0, nil
for _, candidate in ipairs(stockCandidates(item)) do
if remaining <= 0 then break end
local wanted = math.min(remaining, stock[candidate] or 0)
local ok, result = pcall(peripheral.call, tickerName, "requestFiltered", address, {
name = candidate, _requestCount = wanted,
})
if ok then
local sent = tonumber(result) or 0
if sent > 0 then
stock[candidate] = math.max(0, (stock[candidate] or 0) - sent)
sentTotal, remaining = sentTotal + sent, remaining - sent
end
else
lastError = tostring(result)
end
end
return sentTotal, lastError
end
local clearMEWait
local function finishJob(job)
clearMEWait(job)
job.status, job.activeStation, job.finishedAt = "DONE", nil, epoch()
statusText, statusColor = "FERTIG: " .. RECIPES[job.recipeId].label, colors.lime
logEvent("#" .. job.id .. " fertig: " .. RECIPES[job.recipeId].label, colors.lime)
saveState()
end
local function failJob(job, reason)
clearMEWait(job)
job.status, job.activeStation, job.finishedAt = "FAILED", nil, epoch()
statusText, statusColor = "FEHLER #" .. job.id .. ": " .. reason, colors.red
logEvent(statusText, colors.red) addError(job, "FAILED", reason) saveState()
end
local function beginDrain(job)
job.status, job.drainStartedAt, job.lastProgressAt = "DRAINING", epoch(), epoch()
end
local function observeItems(job, items)
local changed = false
job.arrived = job.arrived or {}
for _, item in ipairs(items) do
local current = stockCount(item)
local previous = job.lastObserved[item]
if previous == nil then previous = current end
if current > previous then
job.arrived[item] = (job.arrived[item] or 0) + (current - previous)
changed = true
end
job.lastObserved[item] = current
end
if changed then
job.lastProgressAt, job.drainStartedAt, job.warned = epoch(), epoch(), false
saveState()
end
return changed
end
local newJob
clearMEWait = function(job)
job.meWaitingFor, job.meRequired, job.meRequestedAt, job.meMoved = nil, nil, nil, nil
end
local function waitForME(job, item, required)
-- Create always has priority. Only the difference between the complete
-- requirement and the currently visible Create stock may leave the ME.
local createAvailable = stockCount(item)
if createAvailable >= required then
clearMEWait(job)
return true
end
if not meBridgeName or not peripheral.isPresent(meBridgeName) then
failJob(job, "Zu wenig Input: " .. prettyItemName(item)
.. " (Create " .. formatNumber(createAvailable) .. "/" .. formatNumber(required)
.. ", ME Bridge fehlt)")
return false
end
if not peripheral.isPresent(ME_BUFFER_NAME) then
failJob(job, "ME-Puffer " .. ME_BUFFER_NAME .. " nicht erreichbar")
return false
end
local okOnline, online = pcall(peripheral.call, meBridgeName, "isConnected")
if not okOnline or not online then
failJob(job, "ME Bridge offline fuer " .. prettyItemName(item))
return false
end
if job.meWaitingFor == item then
job.status, job.activeStation, job.waitingFor = "WAITING_ME", nil, item
if epoch() - (job.meRequestedAt or epoch()) < ME_TRANSFER_TIMEOUT then return false end
clearMEWait(job)
createAvailable = stockCount(item)
if createAvailable >= required then return true end
elseif job.meWaitingFor then
clearMEWait(job)
end
local missing = math.max(1, required - createAvailable)
local meFilter = materialTag(item) or item
local okItems, meItems = pcall(peripheral.call, meBridgeName, "getItems", { name = meFilter })
local meAvailable = 0
if okItems and type(meItems) == "table" then
for _, meItem in pairs(meItems) do
if type(meItem) == "table" then
meAvailable = meAvailable + (tonumber(meItem.amount or meItem.count) or 0)
end
end
end
if meAvailable <= 0 then
failJob(job, "Zu wenig Input: " .. prettyItemName(item)
.. " (Create " .. formatNumber(createAvailable) .. "/" .. formatNumber(required)
.. ", ME 0)")
return false
end
local wanted = math.min(missing, meAvailable)
local okExport, result = pcall(peripheral.call, meBridgeName, "exportItem",
{ name = meFilter, count = wanted }, ME_BUFFER_NAME)
local moved = okExport and tonumber(result) or 0
if moved <= 0 then
failJob(job, "ME-Export fehlgeschlagen: " .. prettyItemName(item)
.. (okExport and " (Puffer voll oder nicht erreichbar)" or (" - " .. tostring(result))))
return false
end
job.status, job.activeStation, job.waitingFor = "WAITING_ME", nil, item
job.meWaitingFor, job.meRequired = item, required
job.meRequestedAt, job.meMoved = epoch(), moved
logEvent("#" .. job.id .. " ME-Fallback: " .. formatNumber(moved) .. " "
.. prettyItemName(item) .. " (Create hatte " .. formatNumber(createAvailable) .. ")", colors.purple)
saveState()
return false
end
local function dependencyReleased(job)
if not job.dependencyJobId then return true end
local dependency = findJobById(job.dependencyJobId)
if not dependency then
job.dependencyJobId, job.waitingFor = nil, nil
return true
end
if dependency.status == "FAILED" or dependency.status == "CANCELLED" then
failJob(job, "Vorauftrag #" .. dependency.id .. " fuer "
.. prettyItemName(job.waitingFor or "Input") .. " ist " .. dependency.status)
return false
end
-- The parent may consume the result as soon as the child has registered its
-- complete arrival. Waiting for the whole drain timer is unnecessary, but
-- releasing it before this point makes the parent hide the child's output.
local target = math.max(1, dependency.targetCount or 1)
local registered = (dependency.progress or 0) >= target
if dependency.status ~= "DONE" and not registered then
job.status, job.activeStation = "WAITING_INPUT", nil
return false
end
job.dependencyJobId, job.waitingFor, job.lastDependencyLog = nil, nil, nil
return true
end
local function waitForDependency(job, item, required)
local producerId = PRODUCER_BY_ITEM[item]
if not producerId or producerId == job.recipeId then
return waitForME(job, item, required)
end
if job.dependencyJobId then
local previous = findJobById(job.dependencyJobId)
if previous and (previous.status == "FAILED" or previous.status == "CANCELLED") then
failJob(job, "Vorauftrag #" .. previous.id .. " fuer " .. prettyItemName(item)
.. " ist " .. previous.status)
return false
elseif previous and not terminal(previous) then
job.status, job.activeStation, job.waitingFor = "WAITING_INPUT", nil, item
return false
end
job.dependencyJobId = nil
end
local missing = math.max(1, required - stockCount(item))
local child = findJobByRecipe(producerId)
if not child then child = newJob(producerId, missing, "DEPENDENCY", job.id) end
job.status, job.activeStation = "WAITING_INPUT", nil
job.waitingFor, job.dependencyJobId = item, child and child.id or nil
if job.lastDependencyLog ~= item then
job.lastDependencyLog = item
logEvent("#" .. job.id .. " wartet auf " .. prettyItemName(item)
.. " (Vorauftrag #" .. tostring(child and child.id or "?") .. ")", colors.cyan)
end
saveState()
return false
end
local function dispatchNormal(job, missingOutput)
local recipe = RECIPES[job.recipeId]
if not dependencyReleased(job) then return false end
if job.meWaitingFor then
if not waitForME(job, job.meWaitingFor, job.meRequired or 1) then return false end
end
local selectedStation = chooseStation(recipe, job)
if not selectedStation then job.status = "WAITING" return false end
-- Some processes have a hard per-package limit. Blaze Cake production is
-- capped at 16 because Eggs only stack to 16. The overall job may still
-- target 64 and is continued batch by batch by updateNormal().
local plannedOutput = math.min(missingOutput, recipe.batchOutput or missingOutput)
local inputPlan = {}
if recipe.inputs then
local outputCount = math.max(1, recipe.outputCount or 1)
for _, ingredient in ipairs(recipe.inputs) do
inputPlan[#inputPlan + 1] = {
item = ingredient.item,
count = math.ceil(plannedOutput * ingredient.count / outputCount),
}
end
else
local desiredInput
if recipe.completion == "activity" then desiredInput = recipe.inputCount
else desiredInput = math.ceil(plannedOutput / (recipe.outputCount / recipe.inputCount)) end
inputPlan[1] = { item = recipe.input, count = desiredInput }
end
-- Requirements are kept in the logistics network and are not sent with the
-- main package. Precision Mechanisms use this for their deployer buffers.
for _, requirement in ipairs(recipe.requires or {}) do
if stockCount(requirement.item) < requirement.count then
return waitForDependency(job, requirement.item, requirement.count)
end
end
-- Check the complete recipe before sending its first package. This prevents
-- a mixer basin from receiving only one half of a planned batch.
for _, ingredient in ipairs(inputPlan) do
if ingredient.count <= 0 or stockCount(ingredient.item) < ingredient.count then
return waitForDependency(job, ingredient.item, ingredient.count)
end
end
local sentInputs = {}
for _, ingredient in ipairs(inputPlan) do
local sent, err = request(recipe.address or selectedStation, ingredient.item, ingredient.count)
if sent ~= ingredient.count then
failJob(job, "Teilversand " .. prettyItemName(ingredient.item)
.. ": " .. formatNumber(sent) .. "/" .. formatNumber(ingredient.count)
.. (err and (" - " .. tostring(err)) or ""))
return false
end
sentInputs[ingredient.item] = (sentInputs[ingredient.item] or 0) + sent
job.inputSent = (job.inputSent or 0) + sent
end
job.lastBatchInputs = sentInputs
job.lastBatchInput = nil
job.batchStartProgress = recipe.output and produced(job, recipe.output) or nil
job.batchExpectedOutput = recipe.batchOutput and plannedOutput or nil
job.activeStation, job.status = selectedStation, "RUNNING"
job.waitingFor, job.dependencyJobId, job.lastDependencyLog = nil, nil, nil
clearMEWait(job)
job.phaseStartedAt, job.lastProgressAt, job.warned = epoch(), epoch(), false
local inputTotal = 0
for _, count in pairs(sentInputs) do inputTotal = inputTotal + count end
logEvent("#" .. job.id .. " gestartet: " .. recipe.label .. " (" .. inputTotal .. " Input)", colors.orange)
saveState() return true
end
local function retryOrFail(job, missing, reason)
if job.retries >= MAX_RETRIES then failJob(job, reason .. "; Rest " .. formatNumber(missing)) return end
job.retries = job.retries + 1
addError(job, "RETRY", reason .. "; Rest " .. formatNumber(missing))
logEvent("#" .. job.id .. " Retry " .. job.retries .. "/" .. MAX_RETRIES .. ", Rest " .. formatNumber(missing), colors.orange)
job.activeStation, job.status, job.lastProgressAt = nil, "WAITING", epoch()
saveState()
end
local function updateNormal(job)
local recipe = RECIPES[job.recipeId]
local completionDrainSeconds = recipe.completionDrainSeconds
if completionDrainSeconds == nil then completionDrainSeconds = DRAIN_SECONDS end
local watched = recipe.outputs or { recipe.output }
observeItems(job, watched)
if recipe.completion == "activity" then
if job.status == "WAITING" or job.status == "WAITING_INPUT" or job.status == "WAITING_ME" then
dispatchNormal(job, 1)
return
end
local total = 0
for _, item in ipairs(watched) do total = total + produced(job, item) end
job.progress = total
if total > 0 and job.status ~= "DRAINING" then beginDrain(job) end
if job.status == "DRAINING" and epoch() - job.lastProgressAt >= completionDrainSeconds then finishJob(job)
elseif job.status == "RUNNING" and epoch() - job.lastProgressAt >= DRAIN_SECONDS then retryOrFail(job, 1, "Kein Output angekommen") end
return
end
local current = produced(job, recipe.output)
job.progress = math.min(job.targetCount, current)
local missing = math.max(0, job.targetCount - current)
if missing <= 0 then
if job.status ~= "DRAINING" then beginDrain(job) end
if epoch() - job.lastProgressAt >= completionDrainSeconds then finishJob(job) end
return
end
if recipe.batchOutput and job.status == "RUNNING"
and current >= (job.batchStartProgress or 0) + (job.batchExpectedOutput or recipe.batchOutput) then
job.activeStation, job.status = nil, "WAITING"
job.batchStartProgress, job.batchExpectedOutput = nil, nil
job.phaseStartedAt, job.lastProgressAt, job.warned = epoch(), epoch(), false
logEvent("#" .. job.id .. " Charge fertig; Rest " .. formatNumber(missing), colors.cyan)
saveState()
return
end
if job.status == "WAITING" or job.status == "WAITING_INPUT" or job.status == "WAITING_ME" then dispatchNormal(job, missing)
elseif job.status == "DRAINING" and epoch() - job.lastProgressAt >= DRAIN_SECONDS then
retryOrFail(job, missing, "Nachlauf beendet, Output unvollstaendig")
elseif job.status == "RUNNING" then
local idle = epoch() - job.lastProgressAt
if idle >= WARNING_SECONDS and not job.warned then job.warned = true logEvent("#" .. job.id .. " wartet auf Output", colors.orange) end
if idle >= DRAIN_SECONDS then retryOrFail(job, missing, "Kein weiterer Output") end
end
end
local function dispatchMetalCrushing(job, remaining, plan)
if stationBusy("crushing", job) then return end
local required = math.max(1, math.min(64, math.ceil(remaining / 3)))
local wanted = math.min(stockCount(plan.source), required)
if wanted <= 0 then
waitForME(job, plan.source, required)
return
end
local sent, err = request("crushing", plan.source, wanted)
if sent <= 0 then failJob(job, plan.sourceName .. "-Versand fehlgeschlagen: " .. tostring(err or "unbekannt")) return end
job.activeStation, job.phase, job.status = "crushing", "CRUSHING", "RUNNING"
job.lastBatchInput, job.phaseStartedAt, job.lastProgressAt, job.warned = sent, epoch(), epoch(), false
logEvent("#" .. job.id .. " Teilauftrag: " .. sent .. " " .. plan.sourceName .. " crushen", colors.orange) saveState()
end
local function dispatchMetalWashing(job, remaining, plan)
if stationBusy("fan:washing", job) then return end
local wanted = math.min(stockCount(plan.clump), math.ceil(remaining / 9))
if wanted <= 0 then dispatchMetalCrushing(job, remaining, plan) return end
local sent, err = request("fan:washing", plan.clump, wanted)
if sent <= 0 then failJob(job, plan.name .. "-Clump-Versand fehlgeschlagen: " .. tostring(err or "unbekannt")) return end
job.activeStation, job.phase, job.status = "fan:washing", "WASHING", "RUNNING"
job.lastBatchInput, job.phaseStartedAt, job.lastProgressAt, job.warned = sent, epoch(), epoch(), false
job.phaseOutputBaseline = produced(job, plan.nugget)
job.phaseExpected = sent * 9
logEvent("#" .. job.id .. " Teilauftrag: " .. sent .. " Clumps waschen", colors.orange) saveState()
end
local function updateMetal(job)
local plan = METAL_PLANS[job.recipeId]
observeItems(job, { plan.nugget, plan.clump })
local nuggets = produced(job, plan.nugget)
job.progress = math.min(job.targetCount, nuggets)
local remaining = math.max(0, job.targetCount - nuggets)
if remaining <= 0 then
if job.status ~= "DRAINING" then job.phase = "FINAL_DRAIN" beginDrain(job) end
if epoch() - job.lastProgressAt >= DRAIN_SECONDS then finishJob(job) end
return
end
if job.status == "WAITING" or job.status == "WAITING_ME" then
job.activeStation = nil
if job.meWaitingFor and not waitForME(job, job.meWaitingFor, job.meRequired or 1) then return end
dispatchMetalWashing(job, remaining, plan)
return
end
-- Do not wait for the complete 60-second crushing drain when the material
-- which has already reached storage can satisfy the remaining parent job.
-- Late crushing returns remain valid surplus and are still counted.
if job.phase == "CRUSHING" and stockCount(plan.clump) * 9 >= remaining then
job.activeStation, job.status, job.phase = nil, "WAITING", "PLAN"
logEvent("#" .. job.id .. " genug Clumps angekommen - sofort weiter zum Washing", colors.lime)
saveState()
dispatchMetalWashing(job, remaining, plan)
return
end
local idle = epoch() - job.lastProgressAt
if idle >= WARNING_SECONDS and not job.warned then
job.warned = true logEvent("#" .. job.id .. " " .. tostring(job.phase) .. " wartet auf Output", colors.orange)
end
if idle < DRAIN_SECONDS then return end
job.activeStation = nil
if job.phase == "CRUSHING" then
job.status, job.phase, job.lastProgressAt = "WAITING", "PLAN", epoch() saveState()
elseif job.phase == "WASHING" then
local phaseGain = math.max(0, produced(job, plan.nugget) - (job.phaseOutputBaseline or 0))
if phaseGain >= (job.phaseExpected or 0) then
-- Batch arrived completely, but the parent still needs more: replan.
job.status, job.phase, job.lastProgressAt = "WAITING", "PLAN", epoch()
saveState()
else
retryOrFail(job, remaining, "Washing-Nachlauf beendet")
end
elseif idle >= DRAIN_SECONDS then retryOrFail(job, remaining, plan.name .. "-Auftrag ohne Fortschritt") end
end
local function updateJobs()
if state.mode == "OFF" then return end
for _, job in ipairs(state.jobs) do
if not terminal(job) then
local recipe = RECIPES[job.recipeId]
if not recipe then failJob(job, "Rezept nicht mehr vorhanden")
elseif METAL_PLANS[recipe.planner] then updateMetal(job)
else updateNormal(job) end
end
end
end
newJob = function(recipeId, targetCount, origin, parentJobId)
local recipe = RECIPES[recipeId]
local job = {
id = state.nextJobId, recipeId = recipeId, status = "WAITING",
phase = recipe.planner and "PLAN" or "NORMAL",
targetCount = targetCount or recipe.targetCount or recipe.outputCount or 1,
origin = origin or "MANUAL",
parentJobId = parentJobId,
baseline = {}, lastObserved = {}, arrived = {}, progress = 0, retries = 0,
createdAt = epoch(), lastProgressAt = epoch(), phaseStartedAt = epoch(),
}
state.nextJobId = state.nextJobId + 1
if recipe.output then setBaseline(job, recipe.output) end
for _, output in ipairs(recipe.outputs or {}) do setBaseline(job, output) end
local metalPlan = METAL_PLANS[recipe.planner]
if metalPlan then setBaseline(job, metalPlan.clump) end
state.jobs[#state.jobs + 1] = job
statusText, statusColor = "EINGEREIHT #" .. job.id .. ": " .. recipe.label, colors.orange
logEvent(statusText .. (job.origin == "AUTO" and " [AUTO]" or ""), colors.orange) saveState() return job
end
local function maintainStock()
if state.mode ~= "AUTO" then return end
for _, rule in ipairs(KEEP_STOCK) do
local current = stockCount(rule.item)
if current < rule.target and not findJobByRecipe(rule.recipeId) then
local missing = rule.target - current
newJob(rule.recipeId, missing, "AUTO")
logEvent("KEEP STOCK: " .. prettyItemName(rule.item) .. " "
.. formatNumber(current) .. "/" .. formatNumber(rule.target), colors.cyan)
end
end
end
local function cancelJob(job)
clearMEWait(job)
job.status, job.activeStation, job.finishedAt = "CANCELLED", nil, epoch()
logEvent("#" .. job.id .. " manuell abgebrochen", colors.red)
addError(job, "CANCELLED", "Manuell abgebrochen; bereits versendete Pakete laufen aus")
statusText, statusColor = "ABGEBROCHEN #" .. job.id, colors.red saveState()
end
local function writeAt(x, y, value, foreground, background)
local width, height = monitor.getSize()
if y < 1 or y > height or x > width then return end
monitor.setCursorPos(math.max(1, x), y) monitor.setTextColor(foreground or colors.white)
monitor.setBackgroundColor(background or colors.black)
monitor.write(tostring(value):sub(1, math.max(0, width - x + 1)))
end
local function center(y, value, foreground, background)
local width = monitor.getSize() value = tostring(value)
writeAt(math.max(1, math.floor((width - #value) / 2) + 1), y, value, foreground, background)
end
local function fill(x1, y1, x2, y2, background)
for y = y1, y2 do writeAt(x1, y, string.rep(" ", x2 - x1 + 1), colors.white, background) end
end
local function drawStock(width)
local perPage = 10
stockPageCount = math.max(1, math.ceil(#stockEntries / perPage))
stockPage = math.max(1, math.min(stockPage, stockPageCount))
stockPageButtons = {
{ direction = -1, x1 = 1, x2 = math.floor(width / 2), y1 = 3, y2 = 7, enabled = stockPage > 1 },
{ direction = 1, x1 = math.floor(width / 2) + 1, x2 = width, y1 = 3, y2 = 7, enabled = stockPage < stockPageCount },
}
writeAt(2, 3, stockPage > 1 and "<" or " ", colors.yellow)
center(3, "LAGER " .. stockPage .. "/" .. stockPageCount, colors.cyan)
writeAt(width - 1, 3, stockPage < stockPageCount and ">" or " ", colors.yellow)
local columnWidth, first = math.floor((width - 6) / 2), (stockPage - 1) * perPage + 1
for position = 1, perPage do
local entry = stockEntries[first + position - 1]
if entry then
local x, y = position % 2 == 1 and 3 or columnWidth + 5, 4 + math.floor((position - 1) / 2)
local label, maxLabel = entry.label, math.max(8, columnWidth - 13)
if #label > maxLabel then label = label:sub(1, maxLabel - 1) .. "~" end
writeAt(x, y, label .. ": " .. formatNumber(entry.count), colors.lime)
end
end
end
local function progressText(job)
local recipe = RECIPES[job.recipeId]
local idle = math.max(0, epoch() - (job.lastProgressAt or epoch()))
if job.status == "WAITING_INPUT" then
return "WARTE: " .. prettyItemName(job.waitingFor or "Input")
.. (job.dependencyJobId and (" (#" .. job.dependencyJobId .. ")") or "")
end
if job.status == "WAITING_ME" then
return "ME: " .. prettyItemName(job.meWaitingFor or job.waitingFor or "Input")
.. " " .. formatNumber(stockCount(job.meWaitingFor or ""))
.. "/" .. formatNumber(job.meRequired or 0)
end
if recipe and recipe.completion == "activity" then
return formatNumber(job.progress or 0) .. " ANGEKOMMEN " .. tostring(job.phase) .. " " .. idle .. "s"
end
local target, progress = math.max(1, job.targetCount or 1), math.min(job.targetCount or 1, job.progress or 0)
local percent = math.floor(progress * 1000 / target) / 10
return formatNumber(progress) .. "/" .. formatNumber(target) .. " " .. percent .. "% " .. tostring(job.phase) .. " " .. idle .. "s"
end
local function drawAction(recipeId, x1, y1, x2, y2)
local recipe, job = RECIPES[recipeId], findJobByRecipe(recipeId)
local enabled = state.mode ~= "OFF" and not job
local kind, id = job and "cancel" or "order", job and job.id or recipeId
local isArmed = armed and armed.kind == kind and armed.id == id and epoch() <= armedUntil
local bg, fg = colors.gray, colors.lightGray
if job then bg, fg = isArmed and colors.red or colors.orange, colors.black
elseif enabled then bg, fg = isArmed and colors.orange or colors.green, isArmed and colors.black or colors.white end
fill(x1, y1, x2, y2, bg)
local label = job and (isArmed and "NOCHMALS: ABBRECHEN #" .. job.id or "#" .. job.id .. " " .. progressText(job))
or (isArmed and "NOCHMALS: " .. recipe.label or recipe.label)
if #label > x2 - x1 + 1 then label = label:sub(1, x2 - x1) .. "~" end
writeAt(math.max(x1, math.floor((x1 + x2 - #label) / 2)), y1, label, fg, bg)
buttons[#buttons + 1] = { recipeId = recipeId, job = job, enabled = enabled, x1 = x1, y1 = y1, x2 = x2, y2 = y2 }
end
local function drawModes(width)
modeButtons = {}
local labels, startX = { "AUTO", "MANUAL", "OFF" }, math.floor((width - 30) / 2) + 1
for index, mode in ipairs(labels) do
local x1 = startX + (index - 1) * 10
local bg = state.mode == mode and (mode == "OFF" and colors.red or colors.green) or colors.gray
fill(x1, 9, x1 + 8, 9, bg) writeAt(x1 + math.floor((9 - #mode) / 2), 9, mode, colors.white, bg)
modeButtons[#modeButtons + 1] = { mode = mode, x1 = x1, x2 = x1 + 8, y1 = 9, y2 = 9 }
end
end
local function drawActionPage(width)
local perPage = 6
local pages = math.max(1, math.ceil(#ACTION_ORDER / perPage))
actionPage = math.max(1, math.min(actionPage, pages))
center(10, "< REZEPTE " .. actionPage .. "/" .. pages .. " >", colors.yellow)
actionPageButtons = {
{ direction = -1, x1 = 1, x2 = math.floor(width / 2), y1 = 10, y2 = 10, enabled = actionPage > 1 },
{ direction = 1, x1 = math.floor(width / 2) + 1, x2 = width, y1 = 10, y2 = 10, enabled = actionPage < pages },
}
return (actionPage - 1) * perPage + 1, math.min(#ACTION_ORDER, actionPage * perPage)
end
local function drawLogs(width, height, top)
local half = math.floor(width / 2)
writeAt(2, top, "LIVE-LOG", colors.yellow)
for index, entry in ipairs(events) do
writeAt(2, top + index, ("[" .. entry.time .. "] " .. entry.text):sub(1, half - 3), entry.color)
end
errorPageButtons = {}
if #state.errors == 0 then writeAt(half + 2, top, "FEHLERSPEICHER: leer", colors.gray) return end
local perPage, pages = 3, math.max(1, math.ceil(#state.errors / 3))
errorPage = math.max(1, math.min(errorPage, pages))
writeAt(half + 2, top, "< FEHLERSPEICHER " .. errorPage .. "/" .. pages .. " >", colors.red)
errorPageButtons = {
{ direction = -1, x1 = half + 1, x2 = half + math.floor((width - half) / 2), y1 = top, y2 = height - 1, enabled = errorPage > 1 },
{ direction = 1, x1 = half + math.floor((width - half) / 2) + 1, x2 = width, y1 = top, y2 = height - 1, enabled = errorPage < pages },
}
local first = (errorPage - 1) * perPage + 1
for position = 1, perPage do
local entry = state.errors[first + position - 1]
if entry then
local line = "[" .. entry.time .. "] #" .. entry.jobId .. " " .. entry.code .. ": " .. entry.detail
writeAt(half + 2, top + position, line:sub(1, width - half - 2), colors.red)
end
end
end
local function drawVaults(width, height)
local firstRow = height - 2
writeAt(2, firstRow, "VAULTS", colors.cyan)
if #vaults == 0 then
writeAt(10, firstRow, "keine Vault-Peripherie", colors.gray)
return
end
local x, y = 10, firstRow
for index, vault in ipairs(vaults) do
local percent = math.floor(vault.percent * 10 + 0.5) / 10
local text = "V" .. index .. " " .. percent .. "% (frei " .. formatNumber(vault.free) .. ")"
if x + #text > width then
x, y = 10, y + 1
end
if y >= height then break end
local color = colors.lime
if vault.percent >= 95 then color = colors.red
elseif vault.percent >= 80 then color = colors.orange end
writeAt(x, y, text, color)
x = x + #text + 2
end
end
local function render()
local width, height = monitor.getSize()
monitor.setBackgroundColor(colors.black) monitor.setTextColor(colors.white) monitor.clear() buttons = {}
center(1, "CREATE FACTORY SCHEDULER V" .. VERSION, colors.cyan) center(2, statusText, statusColor)
drawStock(width) drawModes(width)
local top, gap = 11, 2
local buttonWidth = math.floor((width - 6 - gap) / 2)
local left, right = 3, 3 + buttonWidth + gap
local firstAction, lastAction = drawActionPage(width)
for absoluteIndex = firstAction, lastAction do
local index, recipeId = absoluteIndex - firstAction + 1, ACTION_ORDER[absoluteIndex]
drawAction(recipeId, index % 2 == 1 and left or right, top + math.floor((index - 1) / 2) * 2,
(index % 2 == 1 and left or right) + buttonWidth - 1, top + math.floor((index - 1) / 2) * 2)
end
drawLogs(width, height, 17)
drawVaults(width, height)
writeAt(2, height, "Bestellen/Abbrechen/OFF: doppelt beruehren | Nachlauf: " .. DRAIN_SECONDS .. "s", colors.gray)
end
local function inside(x, y, button)
return x >= button.x1 and x <= button.x2 and y >= button.y1 and y <= button.y2
end
local function armOrConfirm(kind, id, callback)
local now = epoch()
if armed and armed.kind == kind and armed.id == id and now <= armedUntil then
armed, armedUntil = nil, 0 callback()
else
armed, armedUntil = { kind = kind, id = id }, now + CONFIRM_SECONDS
statusText, statusColor = "Zur Bestaetigung erneut beruehren", colors.orange
end
end
local function setMode(mode)
local now = epoch()
if state.mode == "OFF" and mode ~= "OFF" and state.pausedAt then
local pausedFor = math.max(0, now - state.pausedAt)
for _, job in ipairs(state.jobs) do
if not terminal(job) then
job.lastProgressAt = (job.lastProgressAt or now) + pausedFor
job.phaseStartedAt = (job.phaseStartedAt or now) + pausedFor
if job.drainStartedAt then job.drainStartedAt = job.drainStartedAt + pausedFor end
end
end
state.pausedAt = nil
elseif mode == "OFF" and state.mode ~= "OFF" then
state.pausedAt = now
end
state.mode = mode
end
local function handleTouch(x, y)
for _, button in ipairs(stockPageButtons) do
if inside(x, y, button) then if button.enabled then stockPage = stockPage + button.direction end return end
end
for _, button in ipairs(errorPageButtons) do
if inside(x, y, button) then if button.enabled then errorPage = errorPage + button.direction end return end
end
for _, button in ipairs(actionPageButtons) do
if inside(x, y, button) then if button.enabled then actionPage = actionPage + button.direction end return end
end
for _, button in ipairs(modeButtons) do
if inside(x, y, button) then
if button.mode == "OFF" then
armOrConfirm("mode", "OFF", function()
setMode("OFF")
statusText, statusColor = "MODUS OFF - Timer pausiert", colors.red
logEvent("Betriebsmodus OFF", colors.red) saveState()
end)
else
setMode(button.mode)
statusText, statusColor = "MODUS " .. button.mode, colors.lime
logEvent("Betriebsmodus " .. button.mode, colors.cyan) saveState()
end
return
end
end
for _, button in ipairs(buttons) do
if inside(x, y, button) then
if button.job then armOrConfirm("cancel", button.job.id, function() cancelJob(button.job) end)
elseif not button.enabled then statusText, statusColor = "Im OFF-Modus keine neuen Auftraege", colors.red
else armOrConfirm("order", button.recipeId, function() newJob(button.recipeId) end) end
return
end
end
end
loadState() refreshStock() refreshVaults()
logEvent("Scheduler V" .. VERSION .. " gestartet auf Computer " .. os.getComputerID(), colors.cyan)
render()
local refreshTimer = os.startTimer(REFRESH_SECONDS)
while true do
local event, p1, p2, p3 = os.pullEvent()
if event == "monitor_touch" and p1 == monitorName then handleTouch(p2, p3) render()
elseif event == "timer" and p1 == refreshTimer then
if armed and epoch() > armedUntil then
armed, armedUntil = nil, 0 statusText, statusColor = "Bestaetigung abgelaufen", colors.gray
end
if refreshStock() then maintainStock() updateJobs() end
refreshVaults()
render() refreshTimer = os.startTimer(REFRESH_SECONDS)
elseif event == "peripheral" or event == "peripheral_detach" then refreshStock() refreshVaults() render() end
end