diff --git a/FS25_ADSmartPickup/adFieldModel.lua b/FS25_ADSmartPickup/adFieldModel.lua new file mode 100644 index 0000000..1afdeb7 --- /dev/null +++ b/FS25_ADSmartPickup/adFieldModel.lua @@ -0,0 +1,163 @@ +-- 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. + +ADFieldModel = {} + +ADFieldModel.READY_RATIO = 0.9 +ADFieldModel.CUT_RATIO = 0.5 +ADFieldModel.CP_MAX_WIDTH = 50 +ADFieldModel.WIDTH_TOLERANCE = 0.05 +ADFieldModel.RETRY_MS = 300000 +ADFieldModel.MAX_COLLECTORS = 2 + +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} + +local function copyList(list) + local copy = {} + for index, value in ipairs(list or {}) do copy[index] = value end + 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), + } + return copy +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 = {}}, + } +end + +function ADFieldModel.workStages(plan) + if plan.product == "hay" then + if plan.rake then + return {"mowing", "tedding", "raking", "collecting"} + end + return {"mowing", "tedding", "collecting"} + end + return {"mowing", "collecting"} +end + +function ADFieldModel.roleFor(stage) + return ADFieldModel.ROLES[stage] +end + +function ADFieldModel.isWorking(field) + return ADFieldModel.ROLES[field.stage] ~= nil +end + +function ADFieldModel.maxRigs(stage) + return stage == "collecting" and ADFieldModel.MAX_COLLECTORS or 1 +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. +function ADFieldModel.onReadiness(field, ratio) + local copy = copyField(field) + if (field.stage == "idle" or field.stage == "done") and ratio >= ADFieldModel.READY_RATIO then + copy.stage = "ready" + elseif field.stage == "ready" and ratio < ADFieldModel.CUT_RATIO then + copy.stage = "idle" + copy.startRequested = false + end + return copy +end + +function ADFieldModel.shouldStart(field) + return field.stage == "ready" and (field.plan.mode == "auto" or field.startRequested == true) +end + +function ADFieldModel.start(field) + local copy = copyField(field) + copy.stage = ADFieldModel.workStages(field.plan)[1] + copy.startRequested = false + copy.retryAt = 0 + copy.reason = nil + copy.mowWidth = nil + return copy +end + +function ADFieldModel.advance(field) + 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 + return copy +end + +-- Stop fra menuen: arbejdet afbrydes; marken står klar igen (manuel start kræves). +function ADFieldModel.stop(field) + local copy = copyField(field) + if ADFieldModel.isWorking(field) then + copy.stage = "ready" + 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) + 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} + end + return true +end + +function ADFieldModel.wantsRig(field, assignedCount, now) + local role = ADFieldModel.roleFor(field.stage) + if role == nil or (field.retryAt or 0) > now or assignedCount >= ADFieldModel.maxRigs(field.stage) then + return nil + end + return role +end + +function ADFieldModel.with(field, changes) + local copy = copyField(field) + for key, value in pairs(changes) do + if key == "plan" then + for planKey, planValue in pairs(value) do copy.plan[planKey] = planValue end + else + copy[key] = value + end + end + return copy +end + +function ADFieldModel.toRecord(field) + 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, " "), + mowWidth = field.mowWidth or 0, + } +end + +function ADFieldModel.fromRecord(record) + local field = ADFieldModel.newField(record.id) + 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 + field.mowWidth = (record.mowWidth or 0) > 0 and record.mowWidth or nil + return field +end diff --git a/tests/test_adFieldModel.lua b/tests/test_adFieldModel.lua new file mode 100644 index 0000000..2737a70 --- /dev/null +++ b/tests/test_adFieldModel.lua @@ -0,0 +1,99 @@ +-- Kør: luajit tests/test_adFieldModel.lua (fra repo-roden) +dofile("FS25_ADSmartPickup/adFieldModel.lua") +local M = ADFieldModel +local failures = 0 +local function check(name, actual, expected) + if actual == expected then print("OK " .. name) else + failures = failures + 1 + print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual))) + end +end + +-- M1: ny mark = venter, manuel ensilage uden riv +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 rake", f.plan.rake, false) + +-- M2: klar ved >= 90 % +check("M2 89 % ikke klar", M.onReadiness(f, 0.89).stage, "idle") +local ready = M.onReadiness(f, 0.9) +check("M2 90 % klar", ready.stage, "ready") +check("M2 immutabel", f.stage, "idle") + +-- M3: klar mark slået af spilleren -> tilbage til venter +check("M3 slået manuelt", M.onReadiness(ready, 0.3).stage, "idle") +check("M3 lidt under 90 % forbliver klar", M.onReadiness(ready, 0.8).stage, "ready") + +-- M4: trin pr. plan +check("M4 ensilage", table.concat(M.workStages({product = "silage"}), ","), "mowing,collecting") +check("M4 hø", table.concat(M.workStages({product = "hay", rake = false}), ","), "mowing,tedding,collecting") +check("M4 hø m. riv", table.concat(M.workStages({product = "hay", rake = true}), ","), "mowing,tedding,raking,collecting") + +-- M5: start: auto starter selv, manuel kun på forespørgsel +check("M5 manuel starter ikke", M.shouldStart(ready), false) +local auto = M.onReadiness(M.newField(5), 1) +auto.plan.mode = "auto" +check("M5 auto starter", M.shouldStart(auto), true) +local requested = M.newField(6); requested.stage = "ready"; requested.startRequested = true +check("M5 Start nu", M.shouldStart(requested), true) +local started = M.start(ready) +check("M5 start -> slås", started.stage, "mowing") +check("M5 start rydder forespørgsel", started.startRequested, false) + +-- M6: videre gennem trinene; færdig mark bliver klar igen når den er vokset +local hay = M.start(M.onReadiness(M.newField(7), 1)); hay.plan.product = "hay" +hay = M.advance(hay); check("M6 hø -> vendes", hay.stage, "tedding") +hay = M.advance(hay); check("M6 -> samles op", hay.stage, "collecting") +hay = M.advance(hay); check("M6 -> færdig", hay.stage, "done") +check("M6 færdig forbliver ved lav andel", M.onReadiness(hay, 0.1).stage, "done") +check("M6 færdig -> klar når vokset", M.onReadiness(hay, 0.95).stage, "ready") +check("M6 arbejdende mark ignorerer andel", M.onReadiness(started, 0).stage, "mowing") +check("M6 isWorking", M.isWorking(started), true) +check("M6 stop -> klar", M.stop(started).stage, "ready") + +-- M7: roller, maks sæt, bredde +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 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") +check("M7 40,8 m slå ok", (M.checkWidth("mower", 40.8, nil)), true) +local okNarrow, whyNarrow, args = M.checkWidth("collector", 20, 40.8) +check("M7 for smal", okNarrow, false); check("M7 grund smal", whyNarrow, "tooNarrow"); check("M7 args", args[2], 40.8) +check("M7 lige bred ok (tolerance)", (M.checkWidth("collector", 40.76, 40.8)), true) +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 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 klar = nil", M.wantsRig(ready, 0, 0), nil) + +-- M8: gem/indlæs bevarer trin, plan og slåbredde +local saved = M.advance(M.start(M.onReadiness(M.newField(109), 1))); saved.mowWidth = 40.8 +saved.plan = {mode = "auto", product = "hay", rake = true, storeBuildingIds = {"b12", "b7"}} +local back = M.fromRecord(M.toRecord(saved)) +check("M8 id", back.id, 109) +check("M8 stage", back.stage, saved.stage) +check("M8 mode", back.plan.mode, "auto") +check("M8 product", back.plan.product, "hay") +check("M8 rake", back.plan.rake, true) +check("M8 lager", table.concat(back.plan.storeBuildingIds, " "), "b12 b7") +check("M8 slåbredde", back.mowWidth, 40.8) +check("M8 ukendt trin -> venter", M.fromRecord({id = 3, stage = "bogus"}).stage, "idle") + +-- M9: with() kopierer og fletter planen +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") + +print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) +os.exit(failures == 0 and 0 or 1)