From 10d96bba81f46233bd6cd8a13950b7b812446410 Mon Sep 17 00:00:00 2001 From: Dystroyer8 Date: Sat, 18 Jul 2026 03:31:27 +0200 Subject: [PATCH] Add persistent Create factory scheduler V1 --- README.md | 16 + create_factory_scheduler.lua | 572 +++++++++++++++++++++++++++++++++++ 2 files changed, 588 insertions(+) create mode 100644 create_factory_scheduler.lua diff --git a/README.md b/README.md index 247717d..4631044 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,22 @@ Modulares CC:Tweaked-Steuerungssystem fuer die ATM10-Megabase. +## Create Factory Scheduler V1 + +`create_factory_scheduler.lua` ist der persistente Nachfolger des Logistiktests. +Der funktionierende `create_factory_test.lua` bleibt als Rueckfallstand erhalten. + +```lua +delete create_factory_scheduler.lua +wget https://git.peli-server.de/Dystroyer8/ATM10_CC_Codes/raw/branch/main/create_factory_scheduler.lua create_factory_scheduler.lua +create_factory_scheduler +``` + +Neue normale Module und Rezepte werden oben in der Tabelle `RECIPES` registriert. +Die Scheduler-Logik muss dafuer nicht veraendert werden. Mehrstufige oder zufaellige +Produktionsketten erhalten einen benannten Planner; V1 enthaelt den Planner fuer +Asurine -> Zinc Clumps/Nuggets -> gewaschene Zinc Nuggets. + ## Create-Fabrik: Peripherie erkennen Das rein lesende Diagnoseprogramm herunterladen und starten: diff --git a/create_factory_scheduler.lua b/create_factory_scheduler.lua new file mode 100644 index 0000000..02ab089 --- /dev/null +++ b/create_factory_scheduler.lua @@ -0,0 +1,572 @@ +-- ATM10 Create factory scheduler V1 +-- Persistent jobs, dependency planning, progress-aware retries and diagnostics. + +local VERSION = "1.0.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 + +-- 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", + }, + 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, + }, + zinc_nuggets = { + label = "576 ZINC NUGGETS", planner = "zinc_nuggets", + output = "alltheores:zinc_nugget", targetCount = 576, + }, +} + +local ACTION_ORDER = { "crush_asurine", "strip_oak", "planks_oak", "shafts", "zinc_nuggets" } + +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") +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 = {}, {} +local stockPage, stockPageCount = 1, 1 +local stockPageButtons, buttons, modeButtons, errorPageButtons = {}, {}, {}, {} +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 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 + job.lastObserved, job.baseline = job.lastObserved or {}, job.baseline or {} + job.retries, job.status = job.retries or 0, job.status or "WAITING" + 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 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 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 produced(job, item) + return math.max(0, (stock[item] or 0) - (job.baseline[item] or 0)) +end + +local function setBaseline(job, item) + if job.baseline[item] == nil then + job.baseline[item], job.lastObserved[item] = stock[item] or 0, stock[item] or 0 + end +end + +local function request(address, item, count) + local ok, result = pcall(peripheral.call, tickerName, "requestFiltered", address, { + name = item, _requestCount = math.max(1, math.floor(count)), + }) + if not ok then return 0, tostring(result) end + local sent = tonumber(result) or 0 + -- Prevent two jobs in the same scheduler tick from reserving the same input. + if sent > 0 then stock[item] = math.max(0, (stock[item] or 0) - sent) end + return sent +end + +local function finishJob(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) + 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 + for _, item in ipairs(items) do + local current = stock[item] or 0 + if current > (job.lastObserved[item] or current) then changed = true end + job.lastObserved[item] = current + end + if changed then + job.lastProgressAt, job.drainStartedAt, job.warned = epoch(), epoch(), false + end + return changed +end + +local function dispatchNormal(job, missingOutput) + local recipe = RECIPES[job.recipeId] + if stationBusy(recipe.station, job) then job.status = "WAITING" return false end + local desiredInput + if recipe.completion == "activity" then desiredInput = recipe.inputCount + else desiredInput = math.ceil(missingOutput / (recipe.outputCount / recipe.inputCount)) end + desiredInput = math.min(desiredInput, stock[recipe.input] or 0) + if desiredInput <= 0 then failJob(job, "Kein Input: " .. prettyItemName(recipe.input)) return false end + local sent, err = request(recipe.address, recipe.input, desiredInput) + if sent <= 0 then failJob(job, "Versand fehlgeschlagen: " .. tostring(err or recipe.input)) return false end + job.inputSent, job.lastBatchInput = (job.inputSent or 0) + sent, sent + job.activeStation, job.status = recipe.station, "RUNNING" + job.phaseStartedAt, job.lastProgressAt, job.warned = epoch(), epoch(), false + logEvent("#" .. job.id .. " gestartet: " .. recipe.label .. " (" .. sent .. " 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 watched = recipe.outputs or { recipe.output } + observeItems(job, watched) + if recipe.completion == "activity" then + 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 >= DRAIN_SECONDS then finishJob(job) + elseif job.status == "RUNNING" and epoch() - job.lastProgressAt >= DRAIN_SECONDS then retryOrFail(job, 1, "Kein Crushing-Output") 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 >= DRAIN_SECONDS then finishJob(job) end + return + end + if job.status == "WAITING" 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 dispatchZincCrushing(job, remaining) + if stationBusy("crushing", job) then return end + local wanted = math.min(stock["create:asurine"] or 0, math.max(1, math.min(64, math.ceil(remaining / 3)))) + if wanted <= 0 then failJob(job, "Zu wenig Asurine fuer fehlende Zinc Nuggets") return end + local sent, err = request("crushing", "create:asurine", wanted) + if sent <= 0 then failJob(job, "Asurine-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 .. " Asurine crushen", colors.orange) saveState() +end + +local function dispatchZincWashing(job, remaining) + if stationBusy("fan:washing", job) then return end + local wanted = math.min(stock["alltheores:zinc_clump"] or 0, math.ceil(remaining / 9)) + if wanted <= 0 then dispatchZincCrushing(job, remaining) return end + local sent, err = request("fan:washing", "alltheores:zinc_clump", wanted) + if sent <= 0 then failJob(job, "Zinc-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, job.phaseExpected = stock["alltheores:zinc_nugget"] or 0, sent * 9 + logEvent("#" .. job.id .. " Teilauftrag: " .. sent .. " Clumps waschen", colors.orange) saveState() +end + +local function updateZinc(job) + observeItems(job, { "alltheores:zinc_nugget", "alltheores:zinc_clump" }) + local nuggets = produced(job, "alltheores:zinc_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" then job.activeStation = nil dispatchZincWashing(job, remaining) 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, (stock["alltheores:zinc_nugget"] or 0) - (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, "Zinkauftrag 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 recipe.planner == "zinc_nuggets" then updateZinc(job) + else updateNormal(job) end + end + end +end + +local function newJob(recipeId) + local recipe = RECIPES[recipeId] + local job = { + id = state.nextJobId, recipeId = recipeId, status = "WAITING", + phase = recipe.planner and "PLAN" or "NORMAL", + targetCount = recipe.targetCount or recipe.outputCount or 1, + baseline = {}, lastObserved = {}, 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 + if recipe.planner == "zinc_nuggets" then setBaseline(job, "alltheores:zinc_clump") end + state.jobs[#state.jobs + 1] = job + statusText, statusColor = "EINGEREIHT #" .. job.id .. ": " .. recipe.label, colors.orange + logEvent(statusText, colors.orange) saveState() return job +end + +local function cancelJob(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 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 + local idle = math.max(0, epoch() - (job.lastProgressAt or epoch())) + 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 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 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 + for index, recipeId in ipairs(ACTION_ORDER) do + 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, top + math.ceil(#ACTION_ORDER / 2) * 2) + 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(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() +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 updateJobs() end + render() refreshTimer = os.startTimer(REFRESH_SECONDS) + elseif event == "peripheral" or event == "peripheral_detach" then refreshStock() render() end +end