Files

644 lines
32 KiB
Lua

-- 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.
-- Samme tick advarer (ADDependency), når spilleren slukker en produktion gården er afhængig af — også uden kæder.
-- 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 = {}, finalKeep = {}, soldByChain = {}, optIn = {}}
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 = {}
-- {[nøgle] = true}: produktioner (ikke kædens) der var tændt ved sidste afhængighedstjek (kun kædens tick, kun på
-- hele data). nil efter indlæsning: det der allerede er slukket ved første tjek, advares der ikke om.
ADChain.depPrevEnabled = nil
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()
-- finalKeep = {[rodnøgle] = true}: slutproduktet beholdes (standard: sælg straks); soldByChain = {["rod|vare"] = true}
-- optIn = {[opskriftsnøgle] = true}: produktioner spilleren selv har slået til som kæde (tændte produktioner er
-- ellers ikke kæder — FS25 tænder dem som standard)
return {byChain = {}, paused = {}, dismissed = {}, userOff = {}, roots = nil, finalKeep = {}, soldByChain = {}, optIn = {}}
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,
finalKeep = sortedKeys(state.finalKeep or {}), soldByChain = sortedKeys(state.soldByChain or {}), optIn = sortedKeys(state.optIn or {})}
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
for _, key in ipairs(records.finalKeep or {}) do state.finalKeep[key] = true end
for _, key in ipairs(records.soldByChain or {}) do state.soldByChain[key] = true end
for _, key in ipairs(records.optIn or {}) do state.optIn[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.
-- optIn = {[nøgle] = true}: kun de produktioner er rødder (andre tændte markeres notChosen: kører for sig selv som
-- kilde, men kæden skaber intet for dem). optIn = nil = alle tændte er rødder (som før).
function ADChain.modelRecipes(recipes, paused, userOff, optIn)
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 optIn ~= nil and recipe.enabled and not recipe.byChain and not optIn[recipe.key] then copy.notChosen = 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"|"auto"|"missing"|"stopped"|"noVehicle"|"pallet"} for hver kant.
function ADChain.flowStatus(edges, runs, noVehicle)
local result = {}
for _, edge in ipairs(edges or {}) do
-- udgangen står på Fordel: spillet leverer selv, intet flow
local status = edge.delivery == "auto" and "auto" or "missing"
for _, run in ipairs(edge.delivery == "auto" and {} or 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,
-- traktorerne: uden dem ligner alle kædeflows tomme (sammenlægning/sletning må kun ramme tomme)
vehicleIds = run.vehicleIds or {}})
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, state.optIn)
local chain = ADChainModel.build(recipes, data.husbandry, data.crops, data.fieldGoods)
-- 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, state.optIn), data.husbandry, data.crops, data.fieldGoods).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), data.harvesting)
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, data.recipes)}
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
-- Afhængighedstjek (spec afsnit 8): nye slukninger (kun act) og "Slukket men nødvendig"-posterne. Uafhængigt af
-- kædens rødder og af markscanningen; springes over ved læsefejl (så beholdes forrige poster, og slukninger i
-- mellemtiden meldes ved næste hele tjek).
-- Produktioner kæden selv er ved at tænde (plan.prod.enable), er ikke "slukket men nødvendig".
local function dependencyCheck(data, plan, runs, act, previous)
if ADDependency == nil or ADChainData.dependencyInputs == nil then return nil end
if data.readFailed then
return previous ~= nil and {warnings = {}, offNeeded = previous.offNeeded or {}} or nil
end
local inputs = ADChainData.dependencyInputs(data, plan.chain, runs)
local enabling, recipes = {}, {}
for _, key in ipairs(plan.prod ~= nil and plan.prod.enable or {}) do enabling[key] = true end
for _, recipe in ipairs(data.recipes or {}) do
if not enabling[recipe.key] then table.insert(recipes, recipe) end
end
local prev = act and ADChain.depPrevEnabled or nil
local result = {
warnings = ADDependency.warnings(prev, recipes, inputs.needed, inputs.stockDays, inputs.capacity, inputs.demand),
offNeeded = ADDependency.offNeeded(recipes, inputs.needed, inputs.stockDays, inputs.capacity, inputs.demand),
}
if act then ADChain.depPrevEnabled = ADDependency.enabledKeys(data.recipes) end
return result
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 okDep, dependency = pcall(dependencyCheck, data, result, runs, act, ADChain.last ~= nil and ADChain.last.dependency or nil)
if not okDep then
Logging.warning(ADChain.LOG_PREFIX .. "afhængighedstjek fejlede: %s", tostring(dependency))
dependency = nil
end
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), finalKeep = copySet(state.finalKeep or {}),
sellable = ADChain.sellableRoots(data, result, runs), candidates = ADChain.candidates(data.recipes, state), data = data, fields = data.fields, fieldsIncomplete = data.fieldsIncomplete == true, titles = data.fillTitles, at = ADChain.now, switchCount = ADChain.switchCount,
dependency = dependency}
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.
-- Højst MAX_PROBLEM_NOTES beskeder pr. tick; resten samles i én besked (alle logges stadig).
ADChain.MAX_PROBLEM_NOTES = 3
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 index, 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)
if index <= ADChain.MAX_PROBLEM_NOTES then ADFieldJobs.notify(text, true) end
end
-- resten samles i én besked (fx mange fermenteringssiloer efter indlæsning); alle står i fanen Kæder
if #fresh > ADChain.MAX_PROBLEM_NOTES then
local ok, text = pcall(string.format, textOf("spu_chain_notifyMore"), #fresh - ADChain.MAX_PROBLEM_NOTES)
ADFieldJobs.notify(ok and text or ("+" .. tostring(#fresh - ADChain.MAX_PROBLEM_NOTES)), true)
end
return #fresh
end
-- Én besked pr. slukning af en nødvendig produktion (kun fra tick; advarslerne tømmes, når de er meldt).
function ADChain.notifyDependencies(last)
local dependency = last ~= nil and last.dependency or nil
if dependency == nil or #(dependency.warnings or {}) == 0 or ADChain.now < ADChain.STARTUP_GRACE_MS then return 0 end
local warnings = dependency.warnings
dependency.warnings = {}
if 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 titleOf = function(name) return tostring((last.titles or {})[name] or name) end
for _, warning in ipairs(warnings) do
local text = ADDependency.notifyText(warning, textOf, titleOf)
Logging.info(ADChain.LOG_PREFIX .. "nødvendig produktion slukket: %s", text)
ADFieldJobs.notify(text, true)
end
return #warnings
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
local okDep, errDep = pcall(ADChain.notifyDependencies, last)
if not okDep then Logging.warning(ADChain.LOG_PREFIX .. "advarsel om slukket produktion fejlede: %s", tostring(errDep)) 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
-- Tændte produktioner der ikke er valgt som kæde (og ikke pauset): {{key, name, buildingName}} til fanen Kæder.
function ADChain.candidates(recipes, state)
local list = {}
for _, recipe in ipairs(recipes or {}) do
if recipe.enabled and not recipe.byChain and not (state.optIn or {})[recipe.key] and not (state.paused or {})[recipe.key] then
table.insert(list, {key = recipe.key, name = recipe.name, buildingName = recipe.buildingName})
end
end
table.sort(list, function(a, b)
if tostring(a.buildingName) ~= tostring(b.buildingName) then return tostring(a.buildingName) < tostring(b.buildingName) end
return tostring(a.name) < tostring(b.name)
end)
return list
end
-- Slå en produktion til/fra som kæde (fra = også pausen ophæves, kæden slipper den ved næste tick).
function ADChain.setOptIn(recipeKey, on)
if recipeKey == nil then return end
ADChain.state.optIn[recipeKey] = on and true or nil
if not on then ADChain.state.paused[recipeKey] = nil end
Logging.info(ADChain.LOG_PREFIX .. "%s %s", tostring(recipeKey), on and "slået til som kæde" or "fjernet fra kæderne")
end
-- Rødder med et slutprodukt der kan sælges straks (gården bruger det ikke selv). -> {[rodnøgle] = true}
function ADChain.sellableRoots(data, result, runs)
local set = {}
local ok, sales = pcall(ADChainActions.finalSales, result.chain, data.recipes, result.edges, {}, ADChainActions.farmUses(data, runs))
for _, sale in ipairs(ok and sales or {}) do set[sale.key] = true end
return set
end
-- Slutprodukt for en kæde: keep = true -> "Behold", ellers "Sælg straks" (standard). Anvendes ved næste tick.
function ADChain.setFinalKeep(rootKey, keep)
if rootKey == nil then return end
ADChain.state.finalKeep[rootKey] = keep and true or nil
Logging.info(ADChain.LOG_PREFIX .. "%s: slutprodukt %s", tostring(rootKey), keep and "beholdes" or "sælges straks")
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
for index, key in ipairs(records.finalKeep) do
xmlFile:setString(string.format("%s.finalKeep.root(%d)#key", ADChain.ROOT, index - 1), key)
end
for index, key in ipairs(records.soldByChain) do
xmlFile:setString(string.format("%s.soldByChain.output(%d)#key", ADChain.ROOT, index - 1), key)
end
for index, key in ipairs(records.optIn) do
xmlFile:setString(string.format("%s.optIn.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 = {}
ADChain.depPrevEnabled = nil
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 = {}, finalKeep = {}, soldByChain = {}, optIn = {}}
xmlFile:iterate(ADChain.ROOT .. ".optIn.recipe", function(_, key)
table.insert(records.optIn, xmlFile:getString(key .. "#key"))
end)
xmlFile:iterate(ADChain.ROOT .. ".finalKeep.root", function(_, key)
table.insert(records.finalKeep, xmlFile:getString(key .. "#key"))
end)
xmlFile:iterate(ADChain.ROOT .. ".soldByChain.output", function(_, key)
table.insert(records.soldByChain, xmlFile:getString(key .. "#key"))
end)
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