fix(kæde): spillerens slukninger huskes, pause beskytter kun sin egen kæde, stoppede flows slippes og forældede nøgler glemmes

This commit is contained in:
2026-09-30 14:24:16 +02:00
parent 9c9c2badeb
commit 605c4e8d45
5 changed files with 131 additions and 37 deletions
+116 -22
View File
@@ -1,8 +1,9 @@
-- AD Chain (kæde-kørsel) -- AD Chain (kæde-kørsel)
-- Hvert minut (efter opstartsfristen): læs gården (ADChainData), byg kæden (ADChainModel), find manglende -- 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. -- 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) -- Resultatet gemmes i ADChain.last til Kæder-fanen. Tilstanden (byChain, pausede rødder, afviste forslag,
-- gemmes i ADSmartPickup_chain.xml under spillets gem. Spiladgang kun inde i funktioner (filen dofile's i test). -- 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 = {}
ADChain.FILE_NAME = "ADSmartPickup_chain.xml" ADChain.FILE_NAME = "ADSmartPickup_chain.xml"
@@ -17,6 +18,10 @@ ADChain.DISMISS_CHANGE = 0.25
ADChain.state = {byChain = {}, paused = {}, dismissed = {}, userOff = {}} ADChain.state = {byChain = {}, paused = {}, dismissed = {}, userOff = {}}
ADChain.last = nil 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 -> true: flows kæden oprettede stoppet, fordi der ikke var traktor/vogn (kun i hukommelsen) -- run-id -> true: flows kæden oprettede stoppet, fordi der ikke var traktor/vogn (kun i hukommelsen)
ADChain.noVehicle = {} ADChain.noVehicle = {}
ADChain.switchCount = 0 ADChain.switchCount = 0
@@ -39,12 +44,14 @@ end
-- ---- rene hjælpere ---- -- ---- rene hjælpere ----
-- userOff = {[recipeKey] = true}: produktioner spilleren slukkede selv — kæden tænder dem ikke igen. -- 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() function ADChain.newState()
return {byChain = {}, paused = {}, dismissed = {}, userOff = {}} return {byChain = {}, paused = {}, dismissed = {}, userOff = {}, roots = nil}
end end
function ADChain.stateToRecords(state) function ADChain.stateToRecords(state)
local records = {byChain = sortedKeys(state.byChain), paused = sortedKeys(state.paused), userOff = sortedKeys(state.userOff), dismissed = {}} 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 = {} local ids = {}
for id in pairs(state.dismissed or {}) do table.insert(ids, id) end for id in pairs(state.dismissed or {}) do table.insert(ids, id) end
table.sort(ids) table.sort(ids)
@@ -61,6 +68,10 @@ function ADChain.stateFromRecords(records)
for _, key in ipairs(records.byChain or {}) do state.byChain[key] = true 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.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.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 for _, entry in ipairs(records.dismissed or {}) do
local id = tonumber(entry.id) local id = tonumber(entry.id)
if id ~= nil and entry.fruit ~= nil and entry.fruit ~= "" then if id ~= nil and entry.fruit ~= nil and entry.fruit ~= "" then
@@ -99,35 +110,81 @@ function ADChain.unionFruits(usedFruits, fieldDemand)
return result return result
end end
-- Kopi af opskrifterne hvor pausede rødder er slukket (så de ikke skaber nye behov). -- Modellens kopi af opskrifterne: pausede rødder markeres paused (kører for sig selv, men skaber intet nyt), og
function ADChain.recipesWithPaused(recipes, paused) -- slukkede userOff-produktioner får 0 cyklusser (de dækker intet behov). Originalerne røres ikke.
function ADChain.modelRecipes(recipes, paused, userOff)
local result = {} local result = {}
for index, recipe in ipairs(recipes or {}) do for index, recipe in ipairs(recipes or {}) do
local copy = {} local copy = {}
for key, value in pairs(recipe) do copy[key] = value end for key, value in pairs(recipe) do copy[key] = value end
if (paused or {})[recipe.key] then copy.enabled = false 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 result[index] = copy
end end
return result return result
end end
-- Spillerens eget valg: en produktion kæden tændte (byChain) som er slukket, selv om kæden stadig har brug for local function copySet(map)
-- den, er slukket af spilleren (eller tændingen virkede ikke) -> userOff. En nøgle ryddes fra userOff, når kæden local result = {}
-- ikke længere har brug for den (men ikke på delvise data). -> {userOff = ny tabel, added = {nye nøgler}} for key, value in pairs(map or {}) do result[key] = value end
function ADChain.playerOverrides(recipes, needRecipes, userOff, isPartial) return result
local result, added = {}, {}
for key in pairs(userOff or {}) do
if isPartial or ((needRecipes or {})[key] or 0) > 0 then result[key] = true end
end 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 for _, recipe in ipairs(recipes or {}) do
local needed = ((needRecipes or {})[recipe.key] or 0) > 0 if recipe.enabled and not recipe.byChain then result[recipe.key] = true end
if recipe.byChain and not recipe.enabled and needed and not result[recipe.key] then end
result[recipe.key] = true return result
table.insert(added, recipe.key) 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
end end
table.sort(added) if not opts.isPartial then
return {userOff = result, added = added} 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 end
-- Rødder hvis flows ikke må stoppes: kædens aktuelle rødder + pausede rødder (pause rører intet). -- Rødder hvis flows ikke må stoppes: kædens aktuelle rødder + pausede rødder (pause rører intet).
@@ -205,17 +262,37 @@ local function farmId()
end end
local function plan(data, state) local function plan(data, state)
local recipes = ADChain.recipesWithPaused(data.recipes, state.paused) local recipes = ADChain.modelRecipes(data.recipes, state.paused, state.userOff)
local chain = ADChainModel.build(recipes, data.husbandry, data.crops) 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) local used = ADChain.unionFruits(data.usedFruits, chain.fieldDemand)
local shortages = ADCropPlan.shortages(chain.fieldDemand, data.stock, data.fields, data.yields) local shortages = ADCropPlan.shortages(chain.fieldDemand, data.stock, data.fields, data.yields)
state.dismissed = ADChain.pruneDismissed(state.dismissed, shortages) state.dismissed = ADChain.pruneDismissed(state.dismissed, shortages)
local actions = ADCropPlan.choose(shortages, data.fields, used, data.yields, data.prices, ADChain.dismissedFruits(state.dismissed)) local actions = ADCropPlan.choose(shortages, data.fields, used, data.yields, data.prices, ADChain.dismissedFruits(state.dismissed))
-- de ægte opskrifter (ikke kopien): en pauset rod er stadig tændt og må ikke "tændes" og overtages af kæden -- 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, next(state.paused) ~= nil) local prod = ADChainActions.productions(data.recipes, chain.needRecipes, protected)
return {chain = chain, shortages = shortages, actions = actions, prod = prod, edges = ADChainActions.edges(chain)} return {chain = chain, shortages = shortages, actions = actions, prod = prod, edges = ADChainActions.edges(chain)}
end 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). -- act = true: udfør handlingerne (tick). Uden act kun en frisk visning (GUI).
function ADChain.evaluate(adEnv, act) function ADChain.evaluate(adEnv, act)
local state = ADChain.state local state = ADChain.state
@@ -226,6 +303,10 @@ function ADChain.evaluate(adEnv, act)
return ADChain.last return ADChain.last
end end
ADChain.collectFailed = false 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) local okPlan, result = pcall(plan, data, state)
if not okPlan then if not okPlan then
Logging.warning(ADChain.LOG_PREFIX .. "kæden kunne ikke beregnes: %s", tostring(result)) Logging.warning(ADChain.LOG_PREFIX .. "kæden kunne ikke beregnes: %s", tostring(result))
@@ -321,6 +402,12 @@ function ADChain.save()
for index, key in ipairs(records.userOff) do for index, key in ipairs(records.userOff) do
xmlFile:setString(string.format("%s.userOff.recipe(%d)#key", ADChain.ROOT, index - 1), key) xmlFile:setString(string.format("%s.userOff.recipe(%d)#key", ADChain.ROOT, index - 1), key)
end 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 for index, entry in ipairs(records.dismissed) do
local key = string.format("%s.dismissed.field(%d)", ADChain.ROOT, index - 1) local key = string.format("%s.dismissed.field(%d)", ADChain.ROOT, index - 1)
xmlFile:setInt(key .. "#id", entry.id) xmlFile:setInt(key .. "#id", entry.id)
@@ -335,6 +422,7 @@ end
function ADChain.load() function ADChain.load()
ADChain.state = ADChain.newState() ADChain.state = ADChain.newState()
ADChain.noVehicle = {} ADChain.noVehicle = {}
ADChain.prevEnabled, ADChain.afterLoad = nil, true
local path = getPath() local path = getPath()
if path == nil or not fileExists(path) then return end if path == nil or not fileExists(path) then return end
local xmlFile = XMLFile.load("ADSmartPickupChain", path) local xmlFile = XMLFile.load("ADSmartPickupChain", path)
@@ -349,6 +437,12 @@ function ADChain.load()
xmlFile:iterate(ADChain.ROOT .. ".userOff.recipe", function(_, key) xmlFile:iterate(ADChain.ROOT .. ".userOff.recipe", function(_, key)
table.insert(records.userOff, xmlFile:getString(key .. "#key")) table.insert(records.userOff, xmlFile:getString(key .. "#key"))
end) 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) xmlFile:iterate(ADChain.ROOT .. ".dismissed.field", function(_, key)
table.insert(records.dismissed, {id = xmlFile:getInt(key .. "#id"), fruit = xmlFile:getString(key .. "#fruit"), table.insert(records.dismissed, {id = xmlFile:getInt(key .. "#id"), fruit = xmlFile:getString(key .. "#fruit"),
missingHa = tonumber(xmlFile:getString(key .. "#missingHa"))}) missingHa = tonumber(xmlFile:getString(key .. "#missingHa"))})
+4 -2
View File
@@ -2,12 +2,14 @@
ADChainActions = {} ADChainActions = {}
function ADChainActions.productions(recipes, needRecipes, anyPaused) -- protected = {[nøgle] = cyklusser}: behov fra pausede rødder — slukkes ikke, men tændes heller ikke.
function ADChainActions.productions(recipes, needRecipes, protected)
local result = {enable = {}, disable = {}} local result = {enable = {}, disable = {}}
for _, recipe in ipairs(recipes or {}) do for _, recipe in ipairs(recipes or {}) do
local needed = ((needRecipes or {})[recipe.key] or 0) > 0 local needed = ((needRecipes or {})[recipe.key] or 0) > 0
local kept = ((protected or {})[recipe.key] or 0) > 0
if needed and not recipe.enabled then table.insert(result.enable, recipe.key) end if needed and not recipe.enabled then table.insert(result.enable, recipe.key) end
if not needed and recipe.enabled and recipe.byChain and not anyPaused then table.insert(result.disable, recipe.key) end if not needed and not kept and recipe.enabled and recipe.byChain then table.insert(result.disable, recipe.key) end
end end
table.sort(result.enable); table.sort(result.disable) table.sort(result.enable); table.sort(result.disable)
return result return result
+2 -1
View File
@@ -52,11 +52,12 @@ function ADChainModel.build(recipes, husbandry, crops)
local producers, herds = indexProducers(recipes), indexHerds(husbandry) local producers, herds = indexProducers(recipes), indexHerds(husbandry)
-- Rødderne kører for sig selv med fuld fart: deres egne input kræves af deres eget træ. Som leverandør til en -- Rødderne kører for sig selv med fuld fart: deres egne input kræves af deres eget træ. Som leverandør til en
-- anden rod har de derfor fuld kapacitet, men deres input regnes ikke med igen (standalone = ingen rekursion). -- anden rod har de derfor fuld kapacitet, men deres input regnes ikke med igen (standalone = ingen rekursion).
-- En pauset rod (recipe.paused) kører også for sig selv, men er ikke rod: kæden skaber intet nyt for den.
local capacity, roots, standalone = {}, {}, {} local capacity, roots, standalone = {}, {}, {}
for _, recipe in ipairs(recipes or {}) do for _, recipe in ipairs(recipes or {}) do
capacity[recipe.key] = recipe.cyclesPerMonth or 0 capacity[recipe.key] = recipe.cyclesPerMonth or 0
if recipe.enabled and not recipe.byChain then if recipe.enabled and not recipe.byChain then
table.insert(roots, recipe) if not recipe.paused then table.insert(roots, recipe) end
standalone[recipe.key] = true standalone[recipe.key] = true
end end
end end
+7 -10
View File
@@ -85,16 +85,11 @@ local function setProduction(data, key, isOn)
return ok return ok
end end
-- Spillerens slukninger er allerede lagt i state.userOff (ADChain.reconcile før planen): de tændes ikke igen.
-- Delvise data: intet slukkes (en rod der ikke kunne læses, ligner en rod der er slået fra). -- Delvise data: intet slukkes (en rod der ikke kunne læses, ligner en rod der er slået fra).
function ADChainRun.toggleProductions(data, prod, needRecipes) function ADChainRun.toggleProductions(data, prod)
local state = ADChain.state local state = ADChain.state
local byChain = state.byChain local byChain = state.byChain
local overrides = ADChain.playerOverrides(data.recipes, needRecipes, state.userOff, data.partial == true)
state.userOff = overrides.userOff
for _, key in ipairs(overrides.added) do
byChain[key] = nil
log("spilleren slukkede %s — kæden tænder den ikke igen", key)
end
for _, key in ipairs(prod.enable or {}) do for _, key in ipairs(prod.enable or {}) do
if not state.userOff[key] and setProduction(data, key, true) then if not state.userOff[key] and setProduction(data, key, true) then
byChain[key] = true byChain[key] = true
@@ -175,7 +170,8 @@ function ADChainRun.createFlows(adEnv, data, edges)
return count return count
end end
-- Flows for rødder der er slået fra: flyt til en anden rod der stadig bruger kanten, ellers stop (slettes ikke). -- Flows for rødder der er slået fra: flyt til en anden rod der stadig bruger kanten, ellers stop og slip (chain
-- ryddes, så spilleren kan starte flowet igen uden at kæden stopper det næste minut; det slettes ikke).
function ADChainRun.stopFlows(adEnv, chain, edges) function ADChainRun.stopFlows(adEnv, chain, edges)
local changed = 0 local changed = 0
local active = ADChain.activeRoots(chain, ADChain.state.paused) local active = ADChain.activeRoots(chain, ADChain.state.paused)
@@ -188,7 +184,8 @@ function ADChainRun.stopFlows(adEnv, chain, edges)
else else
local ok, err = pcall(ADRunsController.stopRun, adEnv, run.id) local ok, err = pcall(ADRunsController.stopRun, adEnv, run.id)
if ok then if ok then
log("flow '%s' stoppet: kæden %s er slået fra", tostring(run.name), tostring(run.chain)) ADRunsController.updateRun(run.id, {chain = ""})
log("flow '%s' stoppet og sluppet af kæden: kæden %s er slået fra", tostring(run.name), tostring(run.chain))
changed = changed + 1 changed = changed + 1
else else
warn("flow '%s' kunne ikke stoppes: %s", tostring(run.name), tostring(err)) warn("flow '%s' kunne ikke stoppes: %s", tostring(run.name), tostring(err))
@@ -202,7 +199,7 @@ end
function ADChainRun.apply(adEnv, data, result) function ADChainRun.apply(adEnv, data, result)
local okFields, errFields = pcall(ADChainRun.switchFields, data, result.actions) local okFields, errFields = pcall(ADChainRun.switchFields, data, result.actions)
if not okFields then warn("markskift fejlede: %s", tostring(errFields)) end if not okFields then warn("markskift fejlede: %s", tostring(errFields)) end
local okProd, errProd = pcall(ADChainRun.toggleProductions, data, result.prod, result.chain.needRecipes) local okProd, errProd = pcall(ADChainRun.toggleProductions, data, result.prod)
if not okProd then warn("mellemled fejlede: %s", tostring(errProd)) end if not okProd then warn("mellemled fejlede: %s", tostring(errProd)) end
local okNew, created = pcall(ADChainRun.createFlows, adEnv, data, result.edges) local okNew, created = pcall(ADChainRun.createFlows, adEnv, data, result.edges)
if not okNew then warn("nye flows fejlede: %s", tostring(created)) end if not okNew then warn("nye flows fejlede: %s", tostring(created)) end
+1 -1
View File
@@ -239,7 +239,7 @@ local function rootRows(rows, root, last, ctx, isExpanded)
end) end)
end end
-- Pausede rødder: recipesWithPaused slukker dem, så modellen ser dem ikke som rødder -> grå linje med [Genoptag]. -- Pausede rødder: modelRecipes markerer dem paused, så modellen ser dem ikke som rødder -> grå linje med [Genoptag].
local function pausedRows(rows, last, ctx) local function pausedRows(rows, last, ctx)
local present, keys = {}, {} local present, keys = {}, {}
for _, root in ipairs(last.chain ~= nil and last.chain.roots or {}) do present[root.key] = true end for _, root in ipairs(last.chain ~= nil and last.chain.roots or {}) do present[root.key] = true end