feat(marker): model for høst, klargøring, pleje og multi-tool (1-5 sæt, aflæsningsvogne)

This commit is contained in:
2026-09-25 23:51:12 +02:00
parent 03c05b7608
commit 9cab4b937d
2 changed files with 342 additions and 61 deletions
+252 -56
View File
@@ -1,7 +1,14 @@
-- AD Field Model
-- Markarbejdets regler som ren data (ingen spil-API): en marks plan og trin, hvornår den er klar,
-- hvilket trin der følger, hvor mange sæt et trin må have, og bredde-reglerne. Alle funktioner
-- returnerer nye tabeller; job-løkken (adFieldJobs) og GUI'et kalder kun herind.
-- hvilket trin der følger, hvor mange sæt et trin må have, pleje-behov og bredde-reglerne. Alle
-- funktioner returnerer nye tabeller; job-løkken (adFieldJobs/adFieldWork) og GUI'et kalder kun herind.
--
-- Markens art (kind): "mowable" (græs/kløver/lucerne), "harvest" (alt andet der høstes), "bare" (ingen
-- afgrøde: stub, pløjet, harvet). Kæder:
-- slåafgrøde: mowing -> [tedding] -> [raking] -> collecting
-- høst: harvesting -> klargøring
-- klargøring: [mulching] -> [liming] -> [plowing|cultivating] -> [sowing] -> [rolling]
-- Pleje under vækst (enkelt trin, derefter tilbage til idle): fertilizing, weeding, liming.
ADFieldModel = {}
@@ -10,11 +17,35 @@ ADFieldModel.CUT_RATIO = 0.5
ADFieldModel.CP_MAX_WIDTH = 50
ADFieldModel.WIDTH_TOLERANCE = 0.05
ADFieldModel.RETRY_MS = 300000
ADFieldModel.MAX_COLLECTORS = 2
ADFieldModel.CP_MAX_TOOL_OFFSET = 10
ADFieldModel.MAX_RIGS = 5
ADFieldModel.MAX_UNLOADERS = 3
ADFieldModel.WEED_MIN = 2 -- weedState 1 = spredt, 2+ = tæt (AI-Bonde/Arbejdsseddel)
ADFieldModel.WEED_MECHANICAL_MAX = 4 -- større ukrudt kræver sprøjte, strigle kan ikke
ADFieldModel.MAX_CARE_RUNS = 2 -- samme pleje højst to gange pr. vækstcyklus (undgår løkker)
ADFieldModel.ROLES = {mowing = "mower", tedding = "tedder", raking = "rake", collecting = "collector"}
local KNOWN_STAGES = {idle = true, ready = true, mowing = true, tedding = true, raking = true, collecting = true, done = true}
ADFieldModel.ROLES = {
mowing = "mower", tedding = "tedder", raking = "rake", collecting = "collector",
harvesting = "harvester", mulching = "mulcher", liming = "limer", plowing = "plow",
cultivating = "cultivator", sowing = "seeder", rolling = "roller", weeding = "weeder",
fertilizing = "fertilizer",
}
ADFieldModel.HELPERS = {harvesting = "unloader"}
ADFieldModel.CARE_STAGES = {fertilizing = true, weeding = true, careLiming = true}
-- trin med afgrøde der skal være væk bagefter (ellers sendes maskinen ud igen)
ADFieldModel.CROP_STAGES = {mowing = true, harvesting = true}
local KNOWN_STAGES = {idle = true, ready = true, done = true}
for stage in pairs(ADFieldModel.ROLES) do KNOWN_STAGES[stage] = true end
KNOWN_STAGES.careLiming = true
local DEFAULT_PLAN = {
mode = "manual", product = "auto", rake = false, rigs = 1, unloaders = 1,
mulch = false, lime = true, soil = "auto", sow = false, sowFruit = "", roll = false,
fertilize = true, weed = true,
}
local SOIL_VALUES = {auto = true, cultivate = true, plow = true, none = true}
local PRODUCTS = {auto = true, silage = true, hay = true, harvest = true}
local function copyList(list)
local copy = {}
@@ -22,35 +53,80 @@ local function copyList(list)
return copy
end
local function copyPlan(plan)
local copy = {}
for key, value in pairs(DEFAULT_PLAN) do copy[key] = value end
for key, value in pairs(plan or {}) do copy[key] = value end
copy.storeBuildingIds = copyList(plan ~= nil and plan.storeBuildingIds or nil)
return copy
end
local function copyField(field)
local copy = {}
for key, value in pairs(field) do copy[key] = value end
copy.plan = {
mode = field.plan.mode, product = field.plan.product, rake = field.plan.rake,
storeBuildingIds = copyList(field.plan.storeBuildingIds),
}
copy.plan = copyPlan(field.plan)
local care = {}
for key, value in pairs(field.careRuns or {}) do care[key] = value end
copy.careRuns = care
return copy
end
local function clamp(value, low, high)
return math.max(low, math.min(high, math.floor(tonumber(value) or low)))
end
function ADFieldModel.newField(id)
return {
id = id, stage = "idle", mowWidth = nil, retryAt = 0, reason = nil, startRequested = false,
plan = {mode = "manual", product = "silage", rake = false, storeBuildingIds = {}},
careRuns = {}, plan = copyPlan(nil),
}
end
-- Hø vendes; Riv kan vælges til både hø og ensilage (samler skårene midt i rivens baner, så også
-- smalle standard-læssevogne kan tage dem).
function ADFieldModel.workStages(plan)
-- Plan "auto": slåafgrøder -> ensilage, alt andet -> høst.
function ADFieldModel.product(plan, kind)
if plan.product ~= nil and plan.product ~= "auto" then return plan.product end
return kind == "mowable" and "silage" or "harvest"
end
-- Klargøringskæden efter høst (eller på en bar mark). needs = {lime = bool, plow = bool} fra marken.
function ADFieldModel.prepStages(plan, needs)
needs = needs or {}
local stages = {}
if plan.mulch then table.insert(stages, "mulching") end
if plan.lime and needs.lime then table.insert(stages, "liming") end
local soil = plan.soil or "auto"
if soil == "plow" or (soil == "auto" and needs.plow) then
table.insert(stages, "plowing")
elseif soil == "cultivate" or soil == "auto" then
table.insert(stages, "cultivating")
end
if plan.sow then
table.insert(stages, "sowing")
if plan.roll then table.insert(stages, "rolling") end
end
return stages
end
-- Hele kæden for markens art. kind nil = slåafgrøde (bagudkompatibelt med etape 1).
function ADFieldModel.workStages(plan, kind, needs)
kind = kind or "mowable"
if kind == "bare" then
return ADFieldModel.prepStages(plan, needs)
end
local product = ADFieldModel.product(plan, kind)
if product == "harvest" then
local stages = {"harvesting"}
for _, stage in ipairs(ADFieldModel.prepStages(plan, needs)) do table.insert(stages, stage) end
return stages
end
local stages = {"mowing"}
if plan.product == "hay" then table.insert(stages, "tedding") end
if product == "hay" then table.insert(stages, "tedding") end
if plan.rake then table.insert(stages, "raking") end
table.insert(stages, "collecting")
return stages
end
-- Krævet bredde for et sæt: kun læssevognen uden riv skal dække slåmaskinens baner. Vender og river kører
-- hele marken i deres egen bredde; efter riv ligger skåret midt i banen, så vognens bredde er ligegyldig.
-- Krævet bredde for et sæt: kun læssevognen uden riv skal dække slåmaskinens baner.
function ADFieldModel.requiredWidth(field, role)
if role == "collector" and not field.plan.rake then
return field.mowWidth
@@ -58,27 +134,46 @@ function ADFieldModel.requiredWidth(field, role)
return nil
end
-- Hvilket tidligere kursus sættet skal køre: læssevognen følger skårene (rivens kursus, ellers
-- slåmaskinens); de andre laver deres eget i deres egen bredde.
-- Hvilket tidligere kursus sættet skal køre: læssevognen følger skårene; de andre laver deres eget.
function ADFieldModel.courseSource(field, role)
if role ~= "collector" then return nil end
return field.plan.rake and "rake" or "mower"
end
function ADFieldModel.roleFor(stage)
if stage == "careLiming" then return "limer" end
return ADFieldModel.ROLES[stage]
end
function ADFieldModel.helperFor(stage)
return ADFieldModel.HELPERS[stage]
end
function ADFieldModel.isWorking(field)
return ADFieldModel.ROLES[field.stage] ~= nil
return ADFieldModel.roleFor(field.stage) ~= nil
end
function ADFieldModel.maxRigs(stage)
return stage == "collecting" and ADFieldModel.MAX_COLLECTORS or 1
function ADFieldModel.isCare(stage)
return ADFieldModel.CARE_STAGES[stage] == true
end
-- Venter/færdig -> klar ved fuld vækst; klar -> venter hvis marken blev slået uden os.
-- Arbejdende marker styres af job-løkken, ikke af vækst.
-- Antal sæt pr. trin (multi-tool 1–5) og aflæsningsvogne under høst (1–3).
function ADFieldModel.maxRigs(stage, plan)
return clamp(plan ~= nil and plan.rigs or 1, 1, ADFieldModel.MAX_RIGS)
end
function ADFieldModel.maxHelpers(stage, plan)
if ADFieldModel.helperFor(stage) == nil then return 0 end
return clamp(plan ~= nil and plan.unloaders or 1, 1, ADFieldModel.MAX_UNLOADERS)
end
-- Courseplays multi-tool-baner (laneOffset) for n sæt: 2 -> -1,1 · 3 -> -1,0,1 · 4 -> -2,-1,1,2 · 5 -> -2..2
function ADFieldModel.lanePositions(n)
local lanes = {[1] = {0}, [2] = {-1, 1}, [3] = {-1, 0, 1}, [4] = {-2, -1, 1, 2}, [5] = {-2, -1, 0, 1, 2}}
return copyList(lanes[clamp(n, 1, 5)])
end
-- Venter/færdig -> klar ved fuld vækst; klar -> venter hvis afgrøden blev taget uden os.
function ADFieldModel.onReadiness(field, ratio)
local copy = copyField(field)
if (field.stage == "idle" or field.stage == "done") and ratio >= ADFieldModel.READY_RATIO then
@@ -102,9 +197,7 @@ function ADFieldModel.forceReady(field)
return field
end
function ADFieldModel.start(field)
local copy = copyField(field)
copy.stage = ADFieldModel.workStages(field.plan)[1]
local function freshCycle(copy)
copy.startRequested = false
copy.retryAt = 0
copy.reason = nil
@@ -112,51 +205,120 @@ function ADFieldModel.start(field)
copy.mowVehicleId = nil
copy.rakeVehicleId = nil
copy.windrowOffset = nil
copy.careRuns = {}
end
-- Første trin i kæden. En tom kæde (bar mark uden jordbehandling eller såning) -> færdig.
function ADFieldModel.start(field, kind, needs)
local copy = copyField(field)
local stages = ADFieldModel.workStages(field.plan, kind, needs)
copy.stage = stages[1] or "done"
copy.kind = kind or "mowable"
freshCycle(copy)
return copy
end
function ADFieldModel.advance(field)
-- Næste trin i kæden (behov genberegnes, så fx kalk springes over hvis jorden allerede er kalket).
-- Pleje-trin går tilbage til idle.
function ADFieldModel.advance(field, needs)
local copy = copyField(field)
local stages = ADFieldModel.workStages(field.plan)
copy.stage = "done"
for index, stage in ipairs(stages) do
if stage == field.stage and stages[index + 1] ~= nil then
copy.stage = stages[index + 1]
end
end
copy.retryAt = 0
copy.reason = nil
if ADFieldModel.isCare(field.stage) then
copy.stage = "idle"
return copy
end
local stages = ADFieldModel.workStages(field.plan, field.kind, needs)
copy.stage = "done"
local passed = false
local order = {"mowing", "tedding", "raking", "collecting", "harvesting", "mulching", "liming", "plowing", "cultivating", "sowing", "rolling"}
local rank = {}
for index, stage in ipairs(order) do rank[stage] = index end
for _, stage in ipairs(stages) do
if not passed and (rank[stage] or 0) > (rank[field.stage] or 0) then
copy.stage = stage
passed = true
end
end
return copy
end
-- Stop fra menuen: arbejdet afbrydes; marken står klar igen (manuel start kræves).
-- Stop fra menuen: arbejdet afbrydes; marken står klar (hovedkæde) eller venter (pleje/klargøring).
function ADFieldModel.stop(field)
local copy = copyField(field)
if ADFieldModel.isWorking(field) then
if ADFieldModel.CROP_STAGES[field.stage] or field.stage == "tedding" or field.stage == "raking" or field.stage == "collecting" then
copy.stage = "ready"
elseif ADFieldModel.isWorking(field) then
copy.stage = "idle"
end
copy.startRequested = false
copy.reason = nil
return copy
end
-- Et sæt må ikke over Courseplays grænse; efter-trin skal dække mindst slåbredden.
function ADFieldModel.checkWidth(role, width, mowWidth)
-- Pleje under vækst: hvilket pleje-trin marken trænger til nu (eller nil). info fra adFields:
-- {kind, growing, fert, fertMax, lime, weed}. Højst MAX_CARE_RUNS pr. pleje pr. cyklus.
function ADFieldModel.careNeed(field, info)
if not (field.stage == "idle" or field.stage == "done") or info == nil or not info.growing then
return nil
end
local runs = field.careRuns or {}
local function allowed(stage) return (runs[stage] or 0) < ADFieldModel.MAX_CARE_RUNS end
if field.plan.weed and info.kind == "harvest" and (info.weed or 0) >= ADFieldModel.WEED_MIN and allowed("weeding") then
return "weeding"
end
if field.plan.fertilize and info.fert ~= nil and info.fertMax ~= nil and info.fert < info.fertMax and allowed("fertilizing") then
return "fertilizing"
end
if field.plan.lime and info.lime ~= nil and info.lime < 1 and allowed("careLiming") then
return "careLiming"
end
return nil
end
-- Start et pleje-trin (tæller kørslen, så samme pleje ikke gentages i det uendelige).
function ADFieldModel.startCare(field, stage)
local copy = copyField(field)
copy.careRuns[stage] = (copy.careRuns[stage] or 0) + 1
copy.stage = stage
copy.startRequested = false
copy.retryAt = 0
copy.reason = nil
return copy
end
-- Et sæt må ikke over Courseplays grænse; efter-trin skal dække mindst den krævede bredde.
function ADFieldModel.checkWidth(role, width, requiredWidth)
if width > ADFieldModel.CP_MAX_WIDTH + ADFieldModel.WIDTH_TOLERANCE then
return false, "tooWide", {width, ADFieldModel.CP_MAX_WIDTH}
end
if role ~= "mower" and mowWidth ~= nil and width + ADFieldModel.WIDTH_TOLERANCE < mowWidth then
return false, "tooNarrow", {width, mowWidth}
if role ~= "mower" and requiredWidth ~= nil and width + ADFieldModel.WIDTH_TOLERANCE < requiredWidth then
return false, "tooNarrow", {width, requiredWidth}
end
return true
end
function ADFieldModel.wantsRig(field, assignedCount, now)
-- Hvilken rolle trinet mangler: {role, count, helper}. Hovedrollen sendes samlet (multi-tool kræver alle
-- baner), hjælpere (aflæsningsvogne) en ad gangen. counts = {primary = aktive, helpers = aktive}.
function ADFieldModel.wantsRig(field, counts, now)
if type(counts) == "number" then counts = {primary = counts, helpers = 0} end
local role = ADFieldModel.roleFor(field.stage)
if role == nil or (field.retryAt or 0) > now or assignedCount >= ADFieldModel.maxRigs(field.stage) then
return nil
if role == nil or (field.retryAt or 0) > now then return nil end
local wanted = ADFieldModel.maxRigs(field.stage, field.plan)
if (counts.primary or 0) < wanted then
return {role = role, count = wanted - (counts.primary or 0), helper = false}
end
return role
local helper = ADFieldModel.helperFor(field.stage)
if helper ~= nil and (counts.helpers or 0) < ADFieldModel.maxHelpers(field.stage, field.plan) then
return {role = helper, count = 1, helper = true}
end
return nil
end
-- Hvilken afgrøde der skal sås: planens valg, ellers den afgrøde der blev høstet.
function ADFieldModel.sowFruit(field)
if field.plan.sowFruit ~= nil and field.plan.sowFruit ~= "" then return field.plan.sowFruit end
return field.lastFruit
end
function ADFieldModel.with(field, changes)
@@ -164,6 +326,8 @@ function ADFieldModel.with(field, changes)
for key, value in pairs(changes) do
if key == "plan" then
for planKey, planValue in pairs(value) do copy.plan[planKey] = planValue end
elseif value == false and (key == "reason") then
copy[key] = nil
else
copy[key] = value
end
@@ -171,15 +335,13 @@ function ADFieldModel.with(field, changes)
return copy
end
-- Læssevognens Courseplay-forskydning når den kører rivens kursus: pickuppen skal ligge over skåret, der
-- ligger windrowOffset m til venstre (positiv) for rivens kursuslinje. Courseplay tillader ±10 m.
-- Læssevognens Courseplay-forskydning når den kører rivens kursus (se etape 1-spec).
function ADFieldModel.collectorOffset(ownOffset, windrowOffset)
local offset = (ownOffset or 0) - (windrowOffset or 0)
return math.max(-ADFieldModel.CP_MAX_TOOL_OFFSET, math.min(ADFieldModel.CP_MAX_TOOL_OFFSET, offset))
end
-- Gæringssilo: en produktion der har markens skår som input og ensilage som output (FermentingSiloPack,
-- GrassSilageSilo m.fl. er produktioner, ikke siloer). productions = {{inputs = {{type}}, outputs = {{type}}}}.
-- Gæringssilo: en produktion der har markens skår som input og ensilage som output.
function ADFieldModel.isFermenter(productions, windrowFillType, silageFillType)
if silageFillType == nil then return false end
for _, production in ipairs(productions or {}) do
@@ -195,10 +357,20 @@ function ADFieldModel.isFermenter(productions, windrowFillType, silageFillType)
return false
end
local function flag(value) return value and 1 or 0 end
function ADFieldModel.toRecord(field)
local plan = field.plan
local care = {}
for stage, runs in pairs(field.careRuns or {}) do table.insert(care, stage .. "=" .. tostring(runs)) end
table.sort(care)
return {
id = field.id, stage = field.stage, mode = field.plan.mode, product = field.plan.product,
rake = field.plan.rake and 1 or 0, store = table.concat(field.plan.storeBuildingIds, " "),
id = field.id, stage = field.stage, mode = plan.mode, product = plan.product,
rake = flag(plan.rake), store = table.concat(plan.storeBuildingIds, " "),
rigs = plan.rigs, unloaders = plan.unloaders, mulch = flag(plan.mulch), lime = flag(plan.lime),
soil = plan.soil, sow = flag(plan.sow), sowFruit = plan.sowFruit or "", roll = flag(plan.roll),
fertilize = flag(plan.fertilize), weed = flag(plan.weed),
kind = field.kind or "", lastFruit = field.lastFruit or "", care = table.concat(care, " "),
mowWidth = field.mowWidth or 0,
mowVehicle = field.mowVehicleId or "",
rakeVehicle = field.rakeVehicleId or "",
@@ -206,16 +378,40 @@ function ADFieldModel.toRecord(field)
}
end
local function readFlag(value, default)
if value == nil then return default end
return value == 1 or value == true
end
local function readText(value)
return value ~= nil and value ~= "" and value or nil
end
function ADFieldModel.fromRecord(record)
local field = ADFieldModel.newField(record.id)
local plan = field.plan
field.stage = KNOWN_STAGES[record.stage] and record.stage or "idle"
field.plan.mode = record.mode == "auto" and "auto" or "manual"
field.plan.product = record.product == "hay" and "hay" or "silage"
field.plan.rake = record.rake == 1 or record.rake == true
for id in string.gmatch(record.store or "", "%S+") do table.insert(field.plan.storeBuildingIds, id) end
plan.mode = record.mode == "auto" and "auto" or "manual"
-- etape 1 gemte "silage"/"hay"; ukendt -> auto
plan.product = PRODUCTS[record.product] and record.product or "auto"
plan.rake = readFlag(record.rake, false)
for id in string.gmatch(record.store or "", "%S+") do table.insert(plan.storeBuildingIds, id) end
plan.rigs = clamp(record.rigs or 1, 1, ADFieldModel.MAX_RIGS)
plan.unloaders = clamp(record.unloaders or 1, 1, ADFieldModel.MAX_UNLOADERS)
plan.mulch = readFlag(record.mulch, false)
plan.lime = readFlag(record.lime, true)
plan.soil = SOIL_VALUES[record.soil] and record.soil or "auto"
plan.sow = readFlag(record.sow, false)
plan.sowFruit = record.sowFruit or ""
plan.roll = readFlag(record.roll, false)
plan.fertilize = readFlag(record.fertilize, true)
plan.weed = readFlag(record.weed, true)
field.kind = readText(record.kind)
field.lastFruit = readText(record.lastFruit)
for stage, runs in string.gmatch(record.care or "", "(%w+)=(%d+)") do field.careRuns[stage] = tonumber(runs) end
field.mowWidth = (record.mowWidth or 0) > 0 and record.mowWidth or nil
field.mowVehicleId = record.mowVehicle ~= nil and record.mowVehicle ~= "" and record.mowVehicle or nil
field.rakeVehicleId = record.rakeVehicle ~= nil and record.rakeVehicle ~= "" and record.rakeVehicle or nil
field.mowVehicleId = readText(record.mowVehicle)
field.rakeVehicleId = readText(record.rakeVehicle)
field.windrowOffset = tonumber(record.windrowOffset)
return field
end
+90 -5
View File
@@ -13,7 +13,9 @@ end
local f = M.newField(109)
check("M1 stage", f.stage, "idle")
check("M1 mode", f.plan.mode, "manual")
check("M1 product", f.plan.product, "silage")
check("M1 product auto", f.plan.product, "auto")
check("M1 auto = ensilage på slåafgrøde", M.product(f.plan, "mowable"), "silage")
check("M1 auto = høst ellers", M.product(f.plan, "harvest"), "harvest")
check("M1 rake", f.plan.rake, false)
-- M2: klar ved >= 90 %
@@ -60,7 +62,7 @@ check("M6 stop -> klar", M.stop(started).stage, "ready")
check("M7 rolle slå", M.roleFor("mowing"), "mower")
check("M7 rolle saml", M.roleFor("collecting"), "collector")
check("M7 rolle klar = ingen", M.roleFor("ready"), nil)
check("M7 maks læssevogne", M.maxRigs("collecting"), 2)
check("M7 maks læssevogne = planens sæt", M.maxRigs("collecting", {rigs = 2}), 2)
check("M7 maks slå", M.maxRigs("mowing"), 1)
local okWide, whyWide = M.checkWidth("mower", 60, nil)
check("M7 over 50 m", okWide, false); check("M7 grund", whyWide, "tooWide")
@@ -71,11 +73,11 @@ check("M7 lige bred ok (tolerance)", (M.checkWidth("collector", 40.76, 40.8)), t
check("M7 ukendt slåbredde ok", (M.checkWidth("tedder", 10, nil)), true)
-- wantsRig: kun arbejdende trin, under maks, efter pause
check("M7 wantsRig slå", M.wantsRig(started, 0, 0), "mower")
check("M7 wantsRig slå", M.wantsRig(started, 0, 0).role, "mower")
check("M7 wantsRig fuld", M.wantsRig(started, 1, 0), nil)
local paused = M.start(M.onReadiness(M.newField(8), 1)); paused.retryAt = 1000
check("M7 wantsRig i pause", M.wantsRig(paused, 0, 500), nil)
check("M7 wantsRig efter pause", M.wantsRig(paused, 0, 1000), "mower")
check("M7 wantsRig efter pause", M.wantsRig(paused, 0, 1000).role, "mower")
check("M7 wantsRig klar = nil", M.wantsRig(ready, 0, 0), nil)
-- M8: gem/indlæs bevarer trin, plan og slåbredde
@@ -98,7 +100,7 @@ local base = M.start(M.onReadiness(M.newField(1), 1))
local noted = M.with(base, {reason = {text = "x"}, retryAt = 5, plan = {product = "hay"}})
check("M9 grund", noted.reason.text, "x"); check("M9 pause", noted.retryAt, 5)
check("M9 plan flettet", noted.plan.product, "hay"); check("M9 mode bevaret", noted.plan.mode, "manual")
check("M9 trin bevaret", noted.stage, "mowing"); check("M9 original urørt", base.plan.product, "silage")
check("M9 trin bevaret", noted.stage, "mowing"); check("M9 original urørt", base.plan.product, "auto")
-- M10: Start nu på en mark der ikke er helt klar -> klar; arbejdende mark røres ikke
check("M10 venter -> klar", M.forceReady(M.newField(2)).stage, "ready")
@@ -142,5 +144,88 @@ check("M13 bageri ikke", M.isFermenter({{inputs = {{type = GRASS_W}}, outputs =
check("M13 ingen produktioner", M.isFermenter(nil, GRASS_W, SILAGE), false)
check("M13 ukendt ensilage-type", M.isFermenter(fermenter, GRASS_W, nil), false)
-- M14: høstkæde + klargøring efter behov
local plan = {mode = "manual", product = "auto", rake = false, rigs = 1, unloaders = 1, mulch = false, lime = true,
soil = "auto", sow = true, sowFruit = "", roll = true, fertilize = true, weed = true, storeBuildingIds = {}}
check("M14 høst + kultivér + så + tromle", table.concat(M.workStages(plan, "harvest", {}), ","), "harvesting,cultivating,sowing,rolling")
check("M14 kalk + pløj når jorden trænger", table.concat(M.workStages(plan, "harvest", {lime = true, plow = true}), ","), "harvesting,liming,plowing,sowing,rolling")
local noSoil = {soil = "none", sow = false, lime = false, mulch = true}
check("M14 kun stubbehakning", table.concat(M.workStages(noSoil, "harvest", {}), ","), "harvesting,mulching")
check("M14 bar mark = kun klargøring", table.concat(M.workStages(plan, "bare", {plow = true}), ","), "plowing,sowing,rolling")
check("M14 tvunget pløj", table.concat(M.prepStages({soil = "plow"}, {}), ","), "plowing")
local harvestField = M.start(M.with(M.newField(20), {plan = {sow = true, soil = "cultivate"}}), "harvest", {})
check("M14 start = høst", harvestField.stage, "harvesting")
check("M14 husker art", harvestField.kind, "harvest")
local afterHarvest = M.advance(harvestField, {})
check("M14 høst -> kultivér", afterHarvest.stage, "cultivating")
check("M14 -> så", M.advance(afterHarvest, {}).stage, "sowing")
check("M14 så -> færdig", M.advance(M.advance(afterHarvest, {}), {}).stage, "done")
check("M14 kalk springes over når jorden er kalket", M.advance(M.with(harvestField, {plan = {lime = true}}), {lime = false}).stage, "cultivating")
check("M14 tom kæde -> færdig", M.start(M.with(M.newField(21), {plan = {soil = "none", sow = false, lime = false}}), "bare", {}).stage, "done")
-- M15: hjælpere under høst og multi-tool-baner
check("M15 aflæsningsvogn efter høsteren", M.wantsRig(harvestField, {primary = 1, helpers = 0}, 0).role, "unloader")
check("M15 hjælper-flag", M.wantsRig(harvestField, {primary = 1, helpers = 0}, 0).helper, true)
check("M15 alle vogne på plads -> intet", M.wantsRig(harvestField, {primary = 1, helpers = 1}, 0), nil)
local multi = M.with(harvestField, {plan = {rigs = 3, unloaders = 2}})
check("M15 multi: 3 høstere samlet", M.wantsRig(multi, {primary = 0, helpers = 0}, 0).count, 3)
check("M15 multi: 2 vogne", M.maxHelpers("harvesting", multi.plan), 2)
check("M15 ingen hjælper ved slåning", M.maxHelpers("mowing", multi.plan), 0)
check("M15 baner 2", table.concat(M.lanePositions(2), ","), "-1,1")
check("M15 baner 3", table.concat(M.lanePositions(3), ","), "-1,0,1")
check("M15 baner 4", table.concat(M.lanePositions(4), ","), "-2,-1,1,2")
check("M15 baner 5", table.concat(M.lanePositions(5), ","), "-2,-1,0,1,2")
check("M15 over 5 -> 5", #M.lanePositions(9), 5)
check("M15 maxRigs holdes 1-5", M.maxRigs("mowing", {rigs = 7}), 5)
-- M16: pleje under vækst
local growing = {kind = "harvest", growing = true, fert = 1, fertMax = 2, lime = 2, weed = 0}
local idleField = M.newField(30)
check("M16 gødning", M.careNeed(idleField, growing), "fertilizing")
check("M16 ukrudt før gødning", M.careNeed(idleField, {kind = "harvest", growing = true, fert = 1, fertMax = 2, lime = 2, weed = 3}), "weeding")
check("M16 græs ukrudtsbehandles ikke", M.careNeed(idleField, {kind = "mowable", growing = true, fert = 2, fertMax = 2, lime = 2, weed = 3}), nil)
check("M16 kalk", M.careNeed(idleField, {kind = "harvest", growing = true, fert = 2, fertMax = 2, lime = 0, weed = 0}), "careLiming")
check("M16 intet behov", M.careNeed(idleField, {kind = "harvest", growing = true, fert = 2, fertMax = 2, lime = 1, weed = 1}), nil)
check("M16 ikke voksende", M.careNeed(idleField, {kind = "harvest", growing = false, fert = 0, fertMax = 2}), nil)
check("M16 gødning slået fra", M.careNeed(M.with(idleField, {plan = {fertilize = false}}), growing), nil)
check("M16 arbejdende mark", M.careNeed(harvestField, growing), nil)
local cared = M.startCare(idleField, "fertilizing")
check("M16 pleje-trin", cared.stage, "fertilizing")
check("M16 rolle", M.roleFor(cared.stage), "fertilizer")
check("M16 kalk-pleje rolle", M.roleFor("careLiming"), "limer")
check("M16 pleje -> idle", M.advance(cared, {}).stage, "idle")
local twice = M.startCare(M.advance(cared, {}), "fertilizing")
check("M16 højst 2 gange", M.careNeed(M.advance(twice, {}), growing), nil)
check("M16 ny cyklus nulstiller", M.careNeed(M.advance(M.start(M.advance(twice, {}), "harvest", {}), {}), growing) ~= "x", true)
check("M16 start nulstiller pleje-tæller", next(M.start(twice, "harvest", {}).careRuns), nil)
check("M16 stop af pleje -> idle", M.stop(cared).stage, "idle")
check("M16 stop af høst -> klar", M.stop(harvestField).stage, "ready")
-- M17: såafgrøde og gem/indlæs af den udvidede plan
check("M17 så samme afgrøde", M.sowFruit(M.with(M.newField(40), {lastFruit = "WHEAT"})), "WHEAT")
check("M17 planens afgrøde vinder", M.sowFruit(M.with(M.newField(40), {lastFruit = "WHEAT", plan = {sowFruit = "CANOLA"}})), "CANOLA")
local full = M.with(M.startCare(M.newField(41), "weeding"), {kind = "harvest", lastFruit = "BARLEY",
plan = {rigs = 3, unloaders = 2, mulch = true, lime = false, soil = "plow", sow = true, sowFruit = "WHEAT", roll = true, fertilize = false, weed = false, product = "harvest"}})
local round = M.fromRecord(M.toRecord(full))
check("M17 trin", round.stage, "weeding")
check("M17 sæt", round.plan.rigs, 3)
check("M17 vogne", round.plan.unloaders, 2)
check("M17 stub", round.plan.mulch, true)
check("M17 kalk fra", round.plan.lime, false)
check("M17 jord", round.plan.soil, "plow")
check("M17 så", round.plan.sow, true)
check("M17 såafgrøde", round.plan.sowFruit, "WHEAT")
check("M17 tromle", round.plan.roll, true)
check("M17 gødning fra", round.plan.fertilize, false)
check("M17 ukrudt fra", round.plan.weed, false)
check("M17 art", round.kind, "harvest")
check("M17 sidste afgrøde", round.lastFruit, "BARLEY")
check("M17 pleje-tæller", round.careRuns.weeding, 1)
check("M17 produkt", round.plan.product, "harvest")
local legacy = M.fromRecord({id = 5, stage = "collecting", mode = "auto", product = "hay", rake = 1})
check("M17 etape 1-post: hø", legacy.plan.product, "hay")
check("M17 etape 1-post: standard kalk til", legacy.plan.lime, true)
check("M17 etape 1-post: 1 sæt", legacy.plan.rigs, 1)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)