-- AD Chain (kæde-kørsel) -- Hvert minut (efter opstartsfristen): læs gården (ADChainData), byg kæden (ADChainModel), find manglende -- afgrøder (ADCropPlan) og lad ADChainRun skifte frie marker, tænde/slukke mellemled og oprette/stoppe flows. -- Resultatet gemmes i ADChain.last til Kæder-fanen. Tilstanden (byChain, pausede rødder, afviste forslag, -- userOff og det sidst kendte sæt af rødder) gemmes i ADSmartPickup_chain.xml under spillets gem. -- Spiladgang kun inde i funktioner (filen dofile's i test). ADChain = {} ADChain.FILE_NAME = "ADSmartPickup_chain.xml" ADChain.ROOT = "ADSmartPickupChain" ADChain.TICK_MS = 60000 ADChain.STARTUP_GRACE_MS = 120000 ADChain.MAX_FIELD_SWITCHES = 2 ADChain.MAX_NEW_FLOWS = 2 ADChain.LOG_PREFIX = "ADSmartPickup: kæde: " -- Et afvist forslag vises igen, når manglen for afgrøden har ændret sig mere end dette. ADChain.DISMISS_CHANGE = 0.25 ADChain.state = {byChain = {}, paused = {}, dismissed = {}, userOff = {}} ADChain.last = nil -- {[nøgle] = true}: produktioner (ikke kædens) der var tændt ved sidste tick — til at se spillerens slukninger ADChain.prevEnabled = nil -- true fra indlæsning til første tick: kædens produktioner der er slukket, glemmes (spillet blev ikke gemt) ADChain.afterLoad = false -- run-id -> "noVehicle"|"pallet" (true = "noVehicle"): flows kæden oprettede stoppet, fordi der ikke var -- traktor/vogn eller varen er på paller (kun i hukommelsen) ADChain.noVehicle = {} ADChain.switchCount = 0 ADChain.now = 0 ADChain.timer = 0 ADChain.collectFailed = false -- {[rodnøgle|vare|art] = true}: manglende led der allerede er meldt som besked (kun i hukommelsen; nulstilles ved indlæsning) ADChain.problemKeys = {} local function contains(list, value) for _, item in ipairs(list or {}) do if item == value then return true end end return false end local function sortedKeys(map) local keys = {} for key in pairs(map or {}) do table.insert(keys, key) end table.sort(keys, function(a, b) return tostring(a) < tostring(b) end) return keys end -- ---- rene hjælpere ---- -- userOff = {[recipeKey] = true}: produktioner spilleren slukkede selv — kæden tænder dem ikke igen. -- roots = {[recipeKey] = true} rødderne ved sidste tick (nil = ukendt, fx gem fra en ældre version). function ADChain.newState() return {byChain = {}, paused = {}, dismissed = {}, userOff = {}, roots = nil} end function ADChain.stateToRecords(state) local records = {byChain = sortedKeys(state.byChain), paused = sortedKeys(state.paused), userOff = sortedKeys(state.userOff), dismissed = {}, roots = state.roots ~= nil and sortedKeys(state.roots) or nil} local ids = {} for id in pairs(state.dismissed or {}) do table.insert(ids, id) end table.sort(ids) for _, id in ipairs(ids) do local entry = state.dismissed[id] table.insert(records.dismissed, {id = id, fruit = entry.fruit, missingHa = entry.missingHa}) end return records end function ADChain.stateFromRecords(records) local state = ADChain.newState() if records == nil then return state end for _, key in ipairs(records.byChain or {}) do state.byChain[key] = true end for _, key in ipairs(records.paused or {}) do state.paused[key] = true end for _, key in ipairs(records.userOff or {}) do state.userOff[key] = true end if records.roots ~= nil then state.roots = {} for _, key in ipairs(records.roots) do state.roots[key] = true end end for _, entry in ipairs(records.dismissed or {}) do local id = tonumber(entry.id) if id ~= nil and entry.fruit ~= nil and entry.fruit ~= "" then state.dismissed[id] = {fruit = entry.fruit, missingHa = tonumber(entry.missingHa) or 0} end end return state end -- Afviste forslag beholdes kun mens manglen for afgrøden findes og har ændret sig højst 25 %. function ADChain.pruneDismissed(dismissed, shortages) local byFruit = {} for _, shortage in ipairs(shortages or {}) do byFruit[shortage.fruit] = shortage.missingHa end local kept = {} for id, entry in pairs(dismissed or {}) do local now = byFruit[entry.fruit] local before = entry.missingHa or 0 if now ~= nil and math.abs(now - before) <= math.abs(before) * ADChain.DISMISS_CHANGE then kept[id] = {fruit = entry.fruit, missingHa = before} end end return kept end -- {[mark] = {fruit, missingHa}} -> {[mark] = afgrøde} (ADCropPlan.choose's dismissed) function ADChain.dismissedFruits(dismissed) local result = {} for id, entry in pairs(dismissed or {}) do result[id] = entry.fruit end return result end function ADChain.unionFruits(usedFruits, fieldDemand) local result = {} for fruit in pairs(usedFruits or {}) do result[fruit] = true end for fruit in pairs(fieldDemand or {}) do result[fruit] = true end return result end -- Modellens kopi af opskrifterne: pausede rødder markeres paused (kører for sig selv, men skaber intet nyt), og -- slukkede userOff-produktioner får 0 cyklusser (de dækker intet behov). Originalerne røres ikke. function ADChain.modelRecipes(recipes, paused, userOff) local result = {} for index, recipe in ipairs(recipes or {}) do local copy = {} for key, value in pairs(recipe) do copy[key] = value end if (paused or {})[recipe.key] then copy.paused = true end if (userOff or {})[recipe.key] and not recipe.enabled then copy.cyclesPerMonth = 0 end result[index] = copy end return result end local function copySet(map) local result = {} for key, value in pairs(map or {}) do result[key] = value end return result end -- Tændte produktioner der ikke er kædens (= rødderne, inkl. pausede). function ADChain.enabledKeys(recipes) local result = {} for _, recipe in ipairs(recipes or {}) do if recipe.enabled and not recipe.byChain then result[recipe.key] = true end end return result end -- Spillerens valg og forældede nøgler, før kæden regner (rent: state røres ikke). -- opts = {prevEnabled, isPartial, firstEval}. -> {userOff, byChain, paused, roots, added, forgotten, droppedPaused, cleared} -- * kædens produktion er slukket: af spilleren -> userOff (added); første tick efter indlæsning -> glemt (forgotten) -- * en produktion (ikke kædens) der var tændt sidste tick, er slukket -> userOff (added) -- * userOff ryddes kun når der kommer en ny rod (spilleren tænder noget) — ikke når noget "ikke længere bruges" -- (det gav tænd/sluk-løkker); nye slukninger i samme tick beholdes -- * pausede nøgler for produktioner der er væk eller slukket, droppes (droppedPaused) -- Delvise data: userOff ryddes ikke, rødderne opdateres ikke og pausede droppes ikke. function ADChain.reconcile(state, recipes, opts) opts = opts or {} local roots = ADChain.enabledKeys(recipes) local rootAdded = false if state.roots ~= nil then for key in pairs(roots) do if not state.roots[key] then rootAdded = true end end end local result = {userOff = copySet(state.userOff), byChain = copySet(state.byChain), paused = copySet(state.paused), roots = opts.isPartial and state.roots or roots, added = {}, forgotten = {}, droppedPaused = {}, cleared = false} if rootAdded and not opts.isPartial and next(result.userOff) ~= nil then result.userOff, result.cleared = {}, true end local present = {} for _, recipe in ipairs(recipes or {}) do present[recipe.key] = recipe if not recipe.enabled and recipe.byChain then result.byChain[recipe.key] = nil if opts.firstEval then table.insert(result.forgotten, recipe.key) elseif not result.userOff[recipe.key] then result.userOff[recipe.key] = true table.insert(result.added, recipe.key) end elseif not recipe.enabled and (opts.prevEnabled or {})[recipe.key] and not result.userOff[recipe.key] then result.userOff[recipe.key] = true table.insert(result.added, recipe.key) end end if not opts.isPartial then for key in pairs(state.paused or {}) do if present[key] == nil or not present[key].enabled then result.paused[key] = nil table.insert(result.droppedPaused, key) end end end table.sort(result.added); table.sort(result.forgotten); table.sort(result.droppedPaused) return result end -- Rødder hvis flows ikke må stoppes: kædens aktuelle rødder + pausede rødder (pause rører intet). function ADChain.activeRoots(chain, paused) local result = {} for _, root in ipairs((chain or {}).roots or {}) do result[root.key] = true end for key in pairs(paused or {}) do result[key] = true end return result end -- Mest brugte Wait-pulje blandt forsyningsflows til bygningen, ellers "". function ADChain.waitPoolFor(runs, building) local counts, best, bestCount = {}, "", 0 for _, run in ipairs(runs or {}) do local group = run.waitPoolGroup if run.kind == "supply" and group ~= nil and group ~= "" and contains(run.targetBuildingIds, building) then counts[group] = (counts[group] or 0) + 1 end end for _, group in ipairs(sortedKeys(counts)) do if counts[group] > bestCount then best, bestCount = group, counts[group] end end return best end function ADChain.flowName(fillTitle, buildingName) return "Kæde: " .. tostring(fillTitle) .. " → " .. tostring(buildingName) end local function covers(run, edge) return contains(run.targetBuildingIds, edge.consumer) and (run.ingredientMode ~= "manual" or contains(run.ingredients, edge.fillType)) end -- Bruger en af de aktuelle kanter stadig flowet? -> kantens rodnøgle (flowet flyttes dertil), ellers nil. function ADChain.stillNeeded(runView, edges) for _, edge in ipairs(edges or {}) do if covers(runView, edge) then return edge.rootKey end end return nil end -- {[forbruger|vare] = "ok"|"missing"|"stopped"|"noVehicle"|"pallet"} for hver kant. function ADChain.flowStatus(edges, runs, noVehicle) local result = {} for _, edge in ipairs(edges or {}) do local status = "missing" for _, run in ipairs(runs or {}) do if run.kind == "supply" and covers(run, edge) then if not run.stopped then status = "ok"; break end local reason = (noVehicle or {})[run.id] if reason == "pallet" then status = "pallet" elseif reason then status = "noVehicle" elseif status == "missing" then status = "stopped" end end end result[tostring(edge.consumer) .. "|" .. tostring(edge.fillType)] = status end return result end -- Flows i det format ADChainActions regner på. function ADChain.runsView(runs) local list = {} for _, run in ipairs(runs or {}) do table.insert(list, {id = run.id, name = run.name, kind = run.kind, targetBuildingIds = run.targetBuildingIds, ingredientMode = run.ingredientMode, ingredients = run.ingredients, stopped = run.stopped == true, chain = run.chain, waitPoolGroup = run.waitPoolGroup}) end return list end -- Manglende led der ikke var der sidst (nøgle = rodnøgle|vare|art). -> nye problemer (hver nøgle én gang), nøglerne nu. -- Et problem der forsvinder, glemmes; kommer det igen, er det nyt. prevKeys røres ikke. function ADChain.problemKey(problem) return tostring(problem.rootKey) .. "|" .. tostring(problem.fillType) .. "|" .. tostring(problem.kind) end function ADChain.newProblems(prevKeys, problems) local fresh, keys = {}, {} for _, problem in ipairs(problems or {}) do local key = ADChain.problemKey(problem) if not keys[key] then keys[key] = true if not (prevKeys or {})[key] then table.insert(fresh, problem) end end end return fresh, keys end -- ---- evaluering ---- local function farmId() return g_currentMission ~= nil and g_currentMission.getFarmId ~= nil and g_currentMission:getFarmId() or 1 end local function plan(data, state) local recipes = ADChain.modelRecipes(data.recipes, state.paused, state.userOff) local chain = ADChainModel.build(recipes, data.husbandry, data.crops) -- pausede rødder: det de bruger, er beskyttet mod slukning (modellen regnet som om de ikke var pauset) local protected = {} if next(state.paused) ~= nil then protected = ADChainModel.build(ADChain.modelRecipes(data.recipes, {}, state.userOff), data.husbandry, data.crops).needRecipes end local used = ADChain.unionFruits(data.usedFruits, chain.fieldDemand) -- ufuldstændig markliste: udbuddet ville være undervurderet -> ingen mangler, forslag eller markskift endnu local shortages, actions = {}, {} if not data.fieldsIncomplete then shortages = ADCropPlan.shortages(chain.fieldDemand, data.stock, data.fields, data.yields) state.dismissed = ADChain.pruneDismissed(state.dismissed, shortages) actions = ADCropPlan.choose(shortages, data.fields, used, data.yields, data.priceOf or data.prices, ADChain.dismissedFruits(state.dismissed)) end -- de ægte opskrifter (ikke kopien): en pauset rod er stadig tændt og må ikke "tændes" og overtages af kæden local prod = ADChainActions.productions(data.recipes, chain.needRecipes, protected) return {chain = chain, shortages = shortages, actions = actions, prod = prod, edges = ADChainActions.edges(chain)} end local function logInfo(fmt, ...) Logging.info(ADChain.LOG_PREFIX .. fmt, ...) end -- Spillerens valg og forældede nøgler ind i tilstanden (kun ved tick, før der regnes). local function applyReconcile(state, data) local result = ADChain.reconcile(state, data.recipes, {prevEnabled = ADChain.prevEnabled, isPartial = data.partial == true, firstEval = ADChain.afterLoad}) if result.cleared then logInfo("ny produktion tændt — slukkede produktioner må bruges igen") end for _, key in ipairs(result.added) do logInfo("spilleren slukkede %s — kæden tænder den ikke igen", key) end for _, key in ipairs(result.forgotten) do logInfo("kæden glemmer %s (ikke tændt efter indlæsning)", key) end for _, key in ipairs(result.droppedPaused) do logInfo("pause for %s fjernet: produktionen er væk eller slukket", key) end state.userOff, state.byChain, state.paused, state.roots = result.userOff, result.byChain, result.paused, result.roots ADChain.prevEnabled = ADChain.enabledKeys(data.recipes) ADChain.afterLoad = false end -- act = true: udfør handlingerne (tick). Uden act kun en frisk visning (GUI). function ADChain.evaluate(adEnv, act) local state = ADChain.state local ok, data = pcall(ADChainData.collect, adEnv, farmId(), state.byChain) if not ok then if not ADChain.collectFailed then Logging.warning(ADChain.LOG_PREFIX .. "gården kunne ikke læses: %s", tostring(data)) end ADChain.collectFailed = true return ADChain.last end ADChain.collectFailed = false if act then local okState, errState = pcall(applyReconcile, state, data) if not okState then Logging.warning(ADChain.LOG_PREFIX .. "tilstanden kunne ikke opdateres: %s", tostring(errState)) end end local okPlan, result = pcall(plan, data, state) if not okPlan then Logging.warning(ADChain.LOG_PREFIX .. "kæden kunne ikke beregnes: %s", tostring(result)) return ADChain.last end if act and ADChainRun ~= nil then ADChainRun.apply(adEnv, data, result) end local runs = ADChain.runsView(ADRunsController.store.runs) local paused = {} for key in pairs(state.paused) do paused[key] = true end ADChain.last = {chain = result.chain, shortages = result.shortages, actions = result.actions, edges = result.edges, missingFlows = ADChainActions.missingFlows(result.edges, runs), flowStatus = ADChain.flowStatus(result.edges, runs, ADChain.noVehicle), paused = paused, userOff = copySet(state.userOff), data = data, fields = data.fields, fieldsIncomplete = data.fieldsIncomplete == true, titles = data.fillTitles, at = ADChain.now, switchCount = ADChain.switchCount} return ADChain.last end -- Alder (ms, spillets ur) af det sidste resultat; math.huge når der intet er. Fanen genbruger et friskt resultat. function ADChain.lastAge() if ADChain.last == nil then return math.huge end return ADChain.now - (ADChain.last.at or 0) end -- Engangsbesked pr. nyt manglende led (kun fra tick). Ikke i opstartsfristen og ikke mens gården er læst delvist -- eller markscanningen kører (ellers falske alarmer lige efter indlæsning) — så huskes intet, og det meldes senere. function ADChain.notifyProblems(last) if last == nil or ADChain.now < ADChain.STARTUP_GRACE_MS then return 0 end if last.fieldsIncomplete or (last.data ~= nil and last.data.partial) then return 0 end local fresh, keys = ADChain.newProblems(ADChain.problemKeys, last.chain ~= nil and last.chain.problems or {}) ADChain.problemKeys = keys if #fresh == 0 or ADFieldJobs == nil or ADFieldJobs.notify == nil then return 0 end local textOf = function(key) return g_i18n ~= nil and g_i18n:getText(key) or key end local fmt = SmartPickupFrame ~= nil and SmartPickupFrame.formatCompact or nil local rootNames = {} for _, root in ipairs(last.chain ~= nil and last.chain.roots or {}) do rootNames[root.key] = tostring((last.titles or {})[root.name] or root.name) end for _, problem in ipairs(fresh) do -- kædens navn foran, så to kæder der mangler samme vare kan skelnes local text = tostring(rootNames[problem.rootKey] or problem.rootKey) .. ": " .. ADChainView.problemText(problem, last, textOf, nil, fmt) Logging.info(ADChain.LOG_PREFIX .. "manglende led: %s", text) ADFieldJobs.notify(text, true) end return #fresh end function ADChain.tick(adEnv, dt) if adEnv == nil then return end ADChain.now = ADChain.now + (dt or 0) ADChain.timer = ADChain.timer + (dt or 0) if ADChain.now < ADChain.STARTUP_GRACE_MS or ADChain.timer < ADChain.TICK_MS then return end ADChain.timer = 0 local last = ADChain.evaluate(adEnv, true) local ok, err = pcall(ADChain.notifyProblems, last) if not ok then Logging.warning(ADChain.LOG_PREFIX .. "besked om manglende led fejlede: %s", tostring(err)) end end -- ---- GUI-handlinger ---- local function suggestionFor(fieldId) for _, action in ipairs(ADChain.last ~= nil and ADChain.last.actions or {}) do if action.fieldId == fieldId then return action end end return nil end -- Godkend forslag: marken får kædens afgrøde som mål (kilde "chain"). function ADChain.approve(fieldId) local action = suggestionFor(fieldId) if action == nil then return false end local ok, err = pcall(function() -- et auto-skift må aldrig overskrive en afgrøde spilleren har sat siden; et forslag godkender spilleren selv if action.mode == "auto" then local plan = ADFieldJobs.getField(fieldId).plan or {} if plan.targetSource == "user" then error("spilleren har selv valgt afgrøde") end end ADFieldJobs.setTarget(fieldId, action.to, "chain") end) if not ok then Logging.warning(ADChain.LOG_PREFIX .. "F%s: forslag kunne ikke godkendes: %s", tostring(fieldId), tostring(err)) return false end Logging.info(ADChain.LOG_PREFIX .. "F%s: forslag godkendt: %s -> %s", tostring(fieldId), tostring(action.from), tostring(action.to)) return true end -- Afvis forslag: vises ikke igen, før manglen for afgrøden ændrer sig > 25 %. function ADChain.dismiss(fieldId) local action = suggestionFor(fieldId) if action == nil then return false end local missingHa = action.hectares or 0 for _, shortage in ipairs(ADChain.last.shortages or {}) do if shortage.fruit == action.to then missingHa = shortage.missingHa end end ADChain.state.dismissed[fieldId] = {fruit = action.to, missingHa = missingHa} Logging.info(ADChain.LOG_PREFIX .. "F%s: forslag %s afvist", tostring(fieldId), tostring(action.to)) return true end function ADChain.setPaused(rootKey, isPaused) if rootKey == nil then return end ADChain.state.paused[rootKey] = isPaused and true or nil Logging.info(ADChain.LOG_PREFIX .. "%s %s", tostring(rootKey), isPaused and "på pause" or "genoptaget") end -- ---- gem / indlæs (samme sti-opslag som runs; skrives kun fra ADRunsController.save) ---- local function getPath() local path = ADRunsStorage ~= nil and ADRunsStorage.getPath() or nil if path == nil then return nil end return (path:gsub(ADRunsStorage.FILE_NAME .. "$", ADChain.FILE_NAME)) end function ADChain.save() local path = getPath() if path == nil then return false end local xmlFile = XMLFile.create("ADSmartPickupChain", path, ADChain.ROOT) if xmlFile == nil then return false end local records = ADChain.stateToRecords(ADChain.state) for index, key in ipairs(records.byChain) do xmlFile:setString(string.format("%s.byChain.recipe(%d)#key", ADChain.ROOT, index - 1), key) end for index, key in ipairs(records.paused) do xmlFile:setString(string.format("%s.paused.root(%d)#key", ADChain.ROOT, index - 1), key) end for index, key in ipairs(records.userOff) do xmlFile:setString(string.format("%s.userOff.recipe(%d)#key", ADChain.ROOT, index - 1), key) end if records.roots ~= nil then xmlFile:setBool(ADChain.ROOT .. ".roots#known", true) for index, key in ipairs(records.roots) do xmlFile:setString(string.format("%s.roots.recipe(%d)#key", ADChain.ROOT, index - 1), key) end end for index, entry in ipairs(records.dismissed) do local key = string.format("%s.dismissed.field(%d)", ADChain.ROOT, index - 1) xmlFile:setInt(key .. "#id", entry.id) xmlFile:setString(key .. "#fruit", entry.fruit) xmlFile:setString(key .. "#missingHa", string.format("%.2f", entry.missingHa or 0)) end xmlFile:save() xmlFile:delete() return true end function ADChain.load() ADChain.state = ADChain.newState() ADChain.noVehicle = {} ADChain.prevEnabled, ADChain.afterLoad = nil, true ADChain.problemKeys = {} local path = getPath() if path == nil or not fileExists(path) then return end local xmlFile = XMLFile.load("ADSmartPickupChain", path) if xmlFile == nil then return end local records = {byChain = {}, paused = {}, userOff = {}, dismissed = {}} xmlFile:iterate(ADChain.ROOT .. ".byChain.recipe", function(_, key) table.insert(records.byChain, xmlFile:getString(key .. "#key")) end) xmlFile:iterate(ADChain.ROOT .. ".paused.root", function(_, key) table.insert(records.paused, xmlFile:getString(key .. "#key")) end) xmlFile:iterate(ADChain.ROOT .. ".userOff.recipe", function(_, key) table.insert(records.userOff, xmlFile:getString(key .. "#key")) end) if xmlFile:getBool(ADChain.ROOT .. ".roots#known", false) then records.roots = {} xmlFile:iterate(ADChain.ROOT .. ".roots.recipe", function(_, key) table.insert(records.roots, xmlFile:getString(key .. "#key")) end) end xmlFile:iterate(ADChain.ROOT .. ".dismissed.field", function(_, key) table.insert(records.dismissed, {id = xmlFile:getInt(key .. "#id"), fruit = xmlFile:getString(key .. "#fruit"), missingHa = tonumber(xmlFile:getString(key .. "#missingHa"))}) end) xmlFile:delete() ADChain.state = ADChain.stateFromRecords(records) Logging.info(ADChain.LOG_PREFIX .. "%d produktioner tændt af kæden, %d pausede kæder indlæst", #records.byChain, #records.paused) end