From bf2817cf7b7bc5f7339b74b6093d78f95b39ae78 Mon Sep 17 00:00:00 2001 From: masterdraco Date: Fri, 25 Sep 2026 21:49:29 +0200 Subject: [PATCH] docs: implementeringsplan markarbejde etape 1 --- .../plans/2026-09-25-markarbejde-etape1.md | 1665 +++++++++++++++++ 1 file changed, 1665 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-25-markarbejde-etape1.md diff --git a/docs/superpowers/plans/2026-09-25-markarbejde-etape1.md b/docs/superpowers/plans/2026-09-25-markarbejde-etape1.md new file mode 100644 index 0000000..9abf19a --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-markarbejde-etape1.md @@ -0,0 +1,1665 @@ +# Markarbejde etape 1 (slåafgrøder) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Smart Pickup sender selv slåmaskine, vender/river og læssevogne ud til græs-/kløver-/lucernemarker og får skårene i den rigtige silo — AutoDrive kører på vej, Courseplay arbejder på marken. + +**Architecture:** Tre rene moduler (model, flåde, visning) med al beslutningslogik og `luajit`-tests; tre tynde spil-moduler (marker, Courseplay-bro, job-løkke) der kun oversætter til spillets/AD's/CP's API; én lagringsfil; én ny ESC-underfane "Marker". Job-løkken tikker hvert sekund fra `ADSmartPickup:update`. + +**Tech Stack:** FS25 Lua (mod-sandbox), luajit til tests, AutoDrive 3.0.1.2 (`FS25_AutoDrive` global), Courseplay 8.1.0.3 (`AIJobType.FIELDWORK_CP`, `vehicle:getIsCpFieldWorkActive`). + +**Spec:** `docs/superpowers/specs/2026-09-25-markarbejde-etape1-design.md` + +## Global Constraints + +- Staging-markør pr. mark: AD-markør med navn `F` (fx `F109`, også `f109`, `F 109`). +- Courseplay-maksbredde: `ADFieldModel.CP_MAX_WIDTH = 50` m. Bredde-tolerance ved sammenligning: 0,05 m. +- Klar-tærskel: `ADFieldModel.READY_RATIO = 0.9`; "slået" = klar-andel < `ADFieldModel.CUT_RATIO = 0.5`. +- Marksilo-afstand: `ADFields.FIELD_SILO_DISTANCE = 150` m. +- Højst 2 læssevogne pr. mark, 1 sæt pr. mark for slå/vende/rive. +- CP-fejl-pause: `ADFieldModel.RETRY_MS = 300000` (5 min). +- Standardplan: `mode = "manual"`, `product = "silage"`, `rake = false`, `storeBuildingIds = {}`. +- Alle spil-kald pcall'es; en fejl i én mark må ikke stoppe de andre. Loglinjer starter `ADSmartPickup: F …`. +- UI: dansk + engelsk nøgler i begge l10n-filer; ingen tegn uden for FS25-fonten (brug `x`, `>=`, `!`, ikke ×/≥/✔). +- Lua i FS25: ingen `goto`; globale moduler (`X = {}`), ikke `local` på modulniveau for ting andre filer skal bruge. + +## Review Focus + +1. Spilleren sætter sig i et sæt midt i arbejdet → sættet frigives, marken står med grund, intet genstarter bag spillerens ryg. (Task 6 test `J3`.) +2. Mark uden `F`-markør eller med markør langt væk → "!" og intet sendes. (Task 4 test `F2`, Task 3 test `V3`.) +3. Læssevogn smallere end slåbredden eller sæt > 50 m → sættet springes over med præcis grund, et andet sæt prøves. (Task 2 test `FL4`–`FL6`.) +4. Genindlæsning midt i "Samles op" → marken fortsætter på samme trin, sæt sendes ud igen. (Task 1 test `M8`, Task 6 test `J5`.) +5. Mark slået manuelt af spilleren mens den står "Klar" → går tilbage til "Venter", intet sendes. (Task 1 test `M3`.) + +--- + +### Task 1: Mark-model (ren) + +**Files:** +- Create: `FS25_ADSmartPickup/adFieldModel.lua` +- Test: `tests/test_adFieldModel.lua` + +**Interfaces:** +- Produces: + - `ADFieldModel.newField(id) -> field` hvor `field = {id, stage, plan = {mode, product, rake, storeBuildingIds}, mowWidth, retryAt, reason, startRequested}` + - `ADFieldModel.workStages(plan) -> {"mowing", ...}` + - `ADFieldModel.roleFor(stage) -> "mower"|"tedder"|"rake"|"collector"|nil` + - `ADFieldModel.maxRigs(stage) -> number` + - `ADFieldModel.onReadiness(field, ratio) -> field` (ny tabel) + - `ADFieldModel.shouldStart(field) -> bool`, `ADFieldModel.start(field) -> field`, `ADFieldModel.advance(field) -> field`, `ADFieldModel.stop(field) -> field` + - `ADFieldModel.isWorking(field) -> bool` + - `ADFieldModel.checkWidth(role, width, mowWidth) -> ok, reason, args` + - `ADFieldModel.wantsRig(field, assignedCount, now) -> role|nil` + - `ADFieldModel.toRecord(field) -> record`, `ADFieldModel.fromRecord(record) -> field` + - `ADFieldModel.with(field, changes) -> field` (kopi med ændrede felter; `changes.plan` flettes ind i planen) + +- [ ] **Step 1: Write the failing test** + +File: tests/test_adFieldModel.lua +```lua +-- 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) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `luajit tests/test_adFieldModel.lua` +Expected: FAIL — `cannot open FS25_ADSmartPickup/adFieldModel.lua` + +- [ ] **Step 3: Write minimal implementation** + +File: FS25_ADSmartPickup/adFieldModel.lua +```lua +-- 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 +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `luajit tests/test_adFieldModel.lua` +Expected: `ALLE TESTS OK` + +- [ ] **Step 5: Commit** + +```bash +git add FS25_ADSmartPickup/adFieldModel.lua tests/test_adFieldModel.lua +git commit -m "feat(marker): ren mark-model — trin, klar-regel, bredde-regler, gem/indlæs" +``` + +--- + +### Task 2: Markflåde (ren) + +**Files:** +- Create: `FS25_ADSmartPickup/adFieldFleet.lua` +- Test: `tests/test_adFieldFleet.lua` + +**Interfaces:** +- Consumes: `ADFieldModel.checkWidth(role, width, mowWidth)` +- Produces: + - `ADFieldFleet.roleFromSpecs(specs) -> role|nil` (`specs = {spec_mower = true, ...}`) + - `ADFieldFleet.pick(candidates, role, x, z, mowWidth) -> candidate|nil, reason, args` hvor `candidate = {id, role, enabled, busy, controlled, width, x, z, label}`; `reason ∈ {"noRig", "allBusy", "tooWide", "tooNarrow"}` + - `ADFieldFleet.toggle(enabledIds, id) -> newEnabledIds` (sæt `{[id]=true}`) + +- [ ] **Step 1: Write the failing test** + +File: tests/test_adFieldFleet.lua +```lua +-- Kør: luajit tests/test_adFieldFleet.lua (fra repo-roden) +dofile("FS25_ADSmartPickup/adFieldModel.lua") +dofile("FS25_ADSmartPickup/adFieldFleet.lua") +local F = ADFieldFleet +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 + +-- FL1: rolle ud fra redskabets spec (læssevogn vinder over andet på samme køretøj) +check("FL1 slåmaskine", F.roleFromSpecs({spec_mower = true}), "mower") +check("FL1 vender", F.roleFromSpecs({spec_tedder = true}), "tedder") +check("FL1 river", F.roleFromSpecs({spec_windrower = true}), "rake") +check("FL1 læssevogn", F.roleFromSpecs({spec_forageWagon = true, spec_mower = true}), "collector") +check("FL1 intet", F.roleFromSpecs({spec_plow = true}), nil) + +local function rig(id, role, x, extra) + local r = {id = id, role = role, enabled = true, busy = false, controlled = false, width = 40.8, x = x, z = 0, label = id} + for k, v in pairs(extra or {}) do r[k] = v end + return r +end + +-- FL2: nærmeste ledige med flueben og rigtig rolle +local picked = F.pick({rig("far", "mower", 500), rig("near", "mower", 50), rig("wagon", "collector", 10)}, "mower", 0, 0, nil) +check("FL2 nærmeste slåmaskine", picked.id, "near") + +-- FL3: uden flueben / optaget / spiller i sædet springes over +local _, why = F.pick({rig("a", "mower", 1, {enabled = false})}, "mower", 0, 0) +check("FL3 intet med flueben", why, "noRig") +local _, whyBusy = F.pick({rig("a", "mower", 1, {busy = true}), rig("b", "mower", 2, {controlled = true})}, "mower", 0, 0) +check("FL3 alle optaget", whyBusy, "allBusy") +check("FL3 ingen med rollen", select(2, F.pick({rig("a", "tedder", 1)}, "mower", 0, 0)), "noRig") + +-- FL4: læssevogn smallere end slåbredden springes over, bredere vælges +local wide = F.pick({rig("smal", "collector", 1, {width = 20}), rig("bred", "collector", 900, {width = 40.8})}, "collector", 0, 0, 40.8) +check("FL4 bred valgt selv om længere væk", wide.id, "bred") +-- FL5: kun for smalle -> grund tooNarrow med tal +local none, whyNarrow, args = F.pick({rig("smal", "collector", 1, {width = 20})}, "collector", 0, 0, 40.8) +check("FL5 ingen", none, nil); check("FL5 grund", whyNarrow, "tooNarrow"); check("FL5 bredde", args[1], 20) +-- FL6: over 50 m -> tooWide +check("FL6 for bred", select(2, F.pick({rig("x", "mower", 1, {width = 60})}, "mower", 0, 0)), "tooWide") + +-- FL7: toggle giver ny tabel +local enabled = {a = true} +local toggled = F.toggle(enabled, "b") +check("FL7 b til", toggled.b, true); check("FL7 original urørt", enabled.b, nil) +check("FL7 a fra", F.toggle(toggled, "a").a, nil) + +print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) +os.exit(failures == 0 and 0 or 1) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `luajit tests/test_adFieldFleet.lua` +Expected: FAIL — `cannot open FS25_ADSmartPickup/adFieldFleet.lua` + +- [ ] **Step 3: Write minimal implementation** + +File: FS25_ADSmartPickup/adFieldFleet.lua +```lua +-- AD Field Fleet +-- Markflåden som ren logik: hvilken rolle et sæt har ud fra redskabernes specs, og hvilket ledigt sæt +-- der sendes til en mark. Kun sæt med flueben bruges; et sæt spilleren sidder i, røres aldrig. + +ADFieldFleet = {} + +-- Rækkefølge = prioritet når et køretøj har flere (læssevogn med egen pickup m.m.) +ADFieldFleet.ROLE_SPECS = { + {role = "collector", spec = "spec_forageWagon"}, + {role = "rake", spec = "spec_windrower"}, + {role = "tedder", spec = "spec_tedder"}, + {role = "mower", spec = "spec_mower"}, +} + +function ADFieldFleet.roleFromSpecs(specs) + for _, entry in ipairs(ADFieldFleet.ROLE_SPECS) do + if specs[entry.spec] then return entry.role end + end + return nil +end + +-- Nærmeste brugbare sæt til rollen. Grund når intet passer: noRig (ingen med flueben og rollen), +-- allBusy (alle optaget/i brug af spilleren), tooWide/tooNarrow (bredden, med tal fra den første). +function ADFieldFleet.pick(candidates, role, x, z, mowWidth) + local best, bestDistance = nil, math.huge + local sawRole, widthReason, widthArgs = false, nil, nil + for _, candidate in ipairs(candidates) do + if candidate.role == role and candidate.enabled then + sawRole = true + if not candidate.busy and not candidate.controlled then + local ok, reason, args = ADFieldModel.checkWidth(role, candidate.width or 0, mowWidth) + if ok then + local dx, dz = candidate.x - x, candidate.z - z + local distance = dx * dx + dz * dz + if distance < bestDistance then best, bestDistance = candidate, distance end + elseif widthReason == nil then + widthReason, widthArgs = reason, args + end + end + end + end + if best ~= nil then return best end + if not sawRole then return nil, "noRig" end + if widthReason ~= nil then return nil, widthReason, widthArgs end + return nil, "allBusy" +end + +function ADFieldFleet.toggle(enabledIds, id) + local copy = {} + for key, value in pairs(enabledIds or {}) do copy[key] = value end + copy[id] = (not copy[id]) or nil + return copy +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `luajit tests/test_adFieldFleet.lua` +Expected: `ALLE TESTS OK` + +- [ ] **Step 5: Commit** + +```bash +git add FS25_ADSmartPickup/adFieldFleet.lua tests/test_adFieldFleet.lua +git commit -m "feat(marker): markflåde — rolle fra redskab, valg af nærmeste ledige sæt m. bredde-regler" +``` + +--- + +### Task 3: Marker-visning (ren) + +**Files:** +- Create: `FS25_ADSmartPickup/adFieldView.lua` +- Test: `tests/test_adFieldView.lua` + +**Interfaces:** +- Consumes: felt-info fra `ADFields.list` (Task 4): `{id, mowable, cropTitle, markerId, readyRatio}` og model-felter `ADFieldModel` +- Produces: + - `ADFieldView.rows(infos, fieldsById, showAll) -> {{info, field}}` (sorteret efter id; kun slåafgrøder uden showAll) + - `ADFieldView.statusKey(field, info) -> l10n-nøgle` (`spu_field_stage_` eller `spu_field_noMarker`) + - `ADFieldView.hiddenCount(infos, showAll) -> number` + +- [ ] **Step 1: Write the failing test** + +File: tests/test_adFieldView.lua +```lua +-- Kør: luajit tests/test_adFieldView.lua (fra repo-roden) +dofile("FS25_ADSmartPickup/adFieldModel.lua") +dofile("FS25_ADSmartPickup/adFieldView.lua") +local V = ADFieldView +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 + +local infos = { + {id = 109, mowable = true, markerId = 5}, + {id = 12, mowable = true, markerId = nil}, + {id = 90, mowable = false, markerId = 7}, +} +local fields = {[109] = ADFieldModel.newField(109), [12] = ADFieldModel.newField(12)} + +-- V1: kun slåafgrøder, sorteret efter nummer +local rows = V.rows(infos, fields, false) +check("V1 to rækker", #rows, 2) +check("V1 sorteret", rows[1].info.id, 12) +check("V1 model med", rows[2].field.id, 109) +-- V2: vis alle tager resten med; mangler model -> ny standardmark +local all = V.rows(infos, fields, true) +check("V2 tre rækker", #all, 3) +check("V2 standardmodel", all[2].field.stage, "idle") +check("V2 skjulte", V.hiddenCount(infos, false), 1) +check("V2 vis alle skjuler intet", V.hiddenCount(infos, true), 0) +-- V3: status — manglende markør vinder over trin +check("V3 mangler markør", V.statusKey(fields[12], infos[2]), "spu_field_noMarker") +check("V3 trin", V.statusKey(fields[109], infos[1]), "spu_field_stage_idle") + +print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) +os.exit(failures == 0 and 0 or 1) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `luajit tests/test_adFieldView.lua` +Expected: FAIL — `cannot open FS25_ADSmartPickup/adFieldView.lua` + +- [ ] **Step 3: Write minimal implementation** + +File: FS25_ADSmartPickup/adFieldView.lua +```lua +-- AD Field View +-- Rene regler for fanen Marker: hvilke marker vises (slåafgrøder som standard), rækkefølge og +-- statusord. GUI'et oversætter nøglerne. + +ADFieldView = {} + +function ADFieldView.rows(infos, fieldsById, showAll) + local rows = {} + for _, info in ipairs(infos or {}) do + if showAll or info.mowable then + table.insert(rows, {info = info, field = fieldsById[info.id] or ADFieldModel.newField(info.id)}) + end + end + table.sort(rows, function(a, b) return a.info.id < b.info.id end) + return rows +end + +function ADFieldView.hiddenCount(infos, showAll) + if showAll then return 0 end + local hidden = 0 + for _, info in ipairs(infos or {}) do + if not info.mowable then hidden = hidden + 1 end + end + return hidden +end + +function ADFieldView.statusKey(field, info) + if info.markerId == nil then + return "spu_field_noMarker" + end + return "spu_field_stage_" .. tostring(field.stage) +end +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `luajit tests/test_adFieldView.lua` +Expected: `ALLE TESTS OK` + +- [ ] **Step 5: Commit** + +```bash +git add FS25_ADSmartPickup/adFieldView.lua tests/test_adFieldView.lua +git commit -m "feat(marker): visningsregler for fanen Marker" +``` + +--- + +### Task 4: Marker i spillet (`adFields.lua`) + +**Files:** +- Create: `FS25_ADSmartPickup/adFields.lua` +- Test: `tests/test_adFields.lua` (kun de rene hjælpere: markørnavn, slåafgrøder ud fra konvertere) + +**Interfaces:** +- Produces: + - `ADFields.parseMarkerName(name) -> fieldId|nil` + - `ADFields.buildMowable(mowerConverter, tedderConverter, fillTypeName) -> {[fruitIndex] = {windrow, hay}}` + - `ADFields.getMowable() -> samme tabel fra spillets konvertere (cachet)` + - `ADFields.list(adEnv, farmId) -> infos` med `info = {id, field, x, z, points, mowable, fruitIndex, cropTitle, readyRatio, markerId, markerName, windrow, hay}`; én mark scannes pr. kald til `ADFields.scanNext()` (round-robin), resten fra cache + - `ADFields.getInfo(id) -> info|nil` + - `ADFields.fieldSilos(adEnv, farmId, info) -> {building, ...}` (siloer ≤ 150 m) + +- [ ] **Step 1: Write the failing test** + +File: tests/test_adFields.lua +```lua +-- Kør: luajit tests/test_adFields.lua (fra repo-roden) +dofile("FS25_ADSmartPickup/adFields.lua") +local F = ADFields +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 + +-- F1/F2: markørnavne +check("F1 F109", F.parseMarkerName("F109"), 109) +check("F1 småt", F.parseMarkerName("f7"), 7) +check("F1 mellemrum", F.parseMarkerName(" F 12 "), 12) +check("F2 andet navn", F.parseMarkerName("Field 109"), nil) +check("F2 tekst efter", F.parseMarkerName("F109 wait"), nil) +check("F2 nil", F.parseMarkerName(nil), nil) + +-- F3: slåafgrøde = MOWER giver et skår som TEDDER gør til DRY…; halm udelukket +local names = {[10] = "GRASS_WINDROW", [11] = "DRYGRASS_WINDROW", [20] = "STRAW", [30] = "CLOVER_WINDROW", [31] = "DRYCLOVER_WINDROW", [40] = "BEANSTRAW"} +local mower = {[1] = {fillTypeIndex = 10}, [2] = {fillTypeIndex = 20}, [3] = {fillTypeIndex = 30}, [4] = {fillTypeIndex = 40}} +local tedder = {[10] = {targetFillTypeIndex = 11}, [30] = {targetFillTypeIndex = 31}, [40] = {targetFillTypeIndex = 20}} +local mowable = F.buildMowable(mower, tedder, function(index) return names[index] end) +check("F3 græs", mowable[1] ~= nil, true) +check("F3 græs skår", mowable[1].windrow, 10) +check("F3 græs hø", mowable[1].hay, 11) +check("F3 rug (halm) ikke", mowable[2], nil) +check("F3 kløver", mowable[3].hay, 31) +check("F3 bønnehalm -> halm ikke", mowable[4], nil) + +print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) +os.exit(failures == 0 and 0 or 1) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `luajit tests/test_adFields.lua` +Expected: FAIL — `cannot open FS25_ADSmartPickup/adFields.lua` + +- [ ] **Step 3: Write implementation** + +File: FS25_ADSmartPickup/adFields.lua +```lua +-- AD Fields +-- Gårdens marker set fra spillet: ejede marker, prøvepunkter i marken, slåafgrøde og klar-andel +-- (density maps, samme metode som Arbejdsseddel — field.fieldState er en frossen NPC-plan og bruges +-- ikke), markens F-markør (AD-markør "F109") og siloer ved marken. Én mark scannes pr. kald. + +ADFields = {} + +ADFields.FIELD_SILO_DISTANCE = 150 +ADFields.MARKER_MAX_DISTANCE = 400 +ADFields.cache = {} +ADFields.order = {} +ADFields.scanIndex = 0 +ADFields.mowable = nil + +function ADFields.parseMarkerName(name) + if type(name) ~= "string" then return nil end + local digits = string.match(name, "^%s*[Ff]%s*(%d+)%s*$") + return digits ~= nil and tonumber(digits) or nil +end + +-- MOWER: frugt -> skår; TEDDER: skår -> DRY…-skår. Kun kæder der ender i DRY… er slåafgrøder. +function ADFields.buildMowable(mowerConverter, tedderConverter, fillTypeName) + local result = {} + for fruitIndex, data in pairs(mowerConverter or {}) do + local windrow = data.fillTypeIndex or data.targetFillTypeIndex + local tedded = windrow ~= nil and tedderConverter ~= nil and tedderConverter[windrow] or nil + local hay = tedded ~= nil and (tedded.targetFillTypeIndex or tedded.fillTypeIndex) or nil + local hayName = hay ~= nil and fillTypeName(hay) or nil + if hay ~= nil and hay ~= windrow and type(hayName) == "string" and string.sub(hayName, 1, 3) == "DRY" then + result[fruitIndex] = {windrow = windrow, hay = hay} + end + end + return result +end + +function ADFields.getMowable() + if ADFields.mowable ~= nil then return ADFields.mowable end + local ok, result = pcall(function() + local mower = g_fruitTypeManager:getConverterDataByName("MOWER") + local tedder = g_fillTypeManager:getConverterDataByName("TEDDER") + return ADFields.buildMowable(mower, tedder, function(index) return g_fillTypeManager:getFillTypeNameByIndex(index) end) + end) + ADFields.mowable = ok and result or {} + if not ok then + Logging.warning("ADSmartPickup: slåafgrøder kunne ikke læses: %s", tostring(result)) + end + return ADFields.mowable +end + +local function fieldOwner(field) + local farmland = field.farmland or (field.getFarmland ~= nil and field:getFarmland()) or nil + if farmland == nil then return nil end + if farmland.id ~= nil and g_farmlandManager ~= nil and g_farmlandManager.getFarmlandOwner ~= nil then + return g_farmlandManager:getFarmlandOwner(farmland.id) + end + return farmland.farmId +end + +local function isOnField(x, z) + local y = getTerrainHeightAtWorldPos(g_currentMission.terrainRootNode, x, 1, z) + return FSDensityMapUtil.getFieldDataAtWorldPosition(x, y, z) and true or false +end + +-- Centroid + halvvejs mod op til 8 hjørner; kun punkter der stadig er mark (bygninger fjerner areal). +local function samplePoints(field) + local verts = {} + for _, node in ipairs(field.polygonPoints or {}) do + if type(node) == "number" then + local x, _, z = getWorldTranslation(node) + table.insert(verts, {x = x, z = z}) + end + end + local cx, cz = field.posX, field.posZ + if (cx == nil or cz == nil) and #verts > 0 then + cx, cz = 0, 0 + for _, v in ipairs(verts) do cx, cz = cx + v.x, cz + v.z end + cx, cz = cx / #verts, cz / #verts + end + if cx == nil then return {}, verts end + local candidates = {{x = cx, z = cz}} + local step = math.max(1, math.floor(#verts / 8)) + for index = 1, #verts, step do + table.insert(candidates, {x = (cx + verts[index].x) / 2, z = (cz + verts[index].z) / 2}) + end + local points = {} + for _, p in ipairs(candidates) do + local ok, onField = pcall(isOnField, p.x, p.z) + if ok and onField then table.insert(points, p) end + end + return points, verts +end + +local function fruitAt(points) + local votes, best, bestVotes = {}, nil, 0 + for _, p in ipairs(points) do + local ok, index = pcall(FSDensityMapUtil.getFruitTypeIndexAtWorldPos, p.x, p.z) + if ok and type(index) == "number" and index > 0 then + votes[index] = (votes[index] or 0) + 1 + if votes[index] > bestVotes then best, bestVotes = index, votes[index] end + end + end + return best +end + +-- Andel af prøvefladen i høstvinduet (samme kald som Arbejdsseddel: any = true,true; klar = false,false). +local function readyRatio(fruitIndex, points) + local anySum, readySum = 0, 0 + for _, p in ipairs(points) do + local okA, _, anyPixels = pcall(FSDensityMapUtil.getFruitArea, fruitIndex, p.x - 2, p.z - 2, p.x + 2, p.z, p.x, p.z + 2, true, true) + if okA and anyPixels then anySum = anySum + anyPixels end + local okR, _, readyPixels = pcall(FSDensityMapUtil.getFruitArea, fruitIndex, p.x - 2, p.z - 2, p.x + 2, p.z, p.x, p.z + 2, false, false) + if okR and readyPixels then readySum = readySum + readyPixels end + end + return anySum > 0 and readySum / anySum or 0 +end + +local function findMarker(adEnv, fieldId, x, z) + local best, bestDistance = nil, math.huge + for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do + if ADFields.parseMarkerName(marker.name) == fieldId then + local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id) + if wayPoint ~= nil then + local distance = math.sqrt((wayPoint.x - x) ^ 2 + (wayPoint.z - z) ^ 2) + if distance < bestDistance then best, bestDistance = marker, distance end + end + end + end + if best ~= nil and bestDistance <= ADFields.MARKER_MAX_DISTANCE then + return best, bestDistance + end + return nil, best ~= nil and bestDistance or nil +end + +local function scanField(adEnv, field, fieldId) + local points, verts = samplePoints(field) + local info = {id = fieldId, field = field, points = points, verts = verts, mowable = false, readyRatio = 0} + info.x, info.z = field.posX, field.posZ + if (info.x == nil or info.z == nil) and points[1] ~= nil then info.x, info.z = points[1].x, points[1].z end + info.fruitIndex = #points > 0 and fruitAt(points) or nil + local mowable = info.fruitIndex ~= nil and ADFields.getMowable()[info.fruitIndex] or nil + if info.fruitIndex ~= nil then + local fruit = g_fruitTypeManager:getFruitTypeByIndex(info.fruitIndex) + local fillType = fruit ~= nil and fruit.fillType or nil + info.cropTitle = fillType ~= nil and fillType.title or (fruit ~= nil and fruit.name) or "?" + end + if mowable ~= nil then + info.mowable, info.windrow, info.hay = true, mowable.windrow, mowable.hay + info.readyRatio = readyRatio(info.fruitIndex, points) + end + if info.x ~= nil then + local marker, distance = findMarker(adEnv, fieldId, info.x, info.z) + info.markerId = marker ~= nil and marker.id or nil + info.markerName = marker ~= nil and marker.name or nil + info.markerDistance = distance + end + return info +end + +local function ownedFields(farmId) + local result = {} + local all = g_fieldManager ~= nil and (g_fieldManager:getFields() or g_fieldManager.fields) or {} + for _, field in pairs(all) do + local id = field.getId ~= nil and field:getId() or field.fieldId + if id ~= nil and fieldOwner(field) == farmId then + table.insert(result, {id = id, field = field}) + end + end + table.sort(result, function(a, b) return a.id < b.id end) + return result +end + +-- Scanner næste mark (round-robin) og returnerer alle kendte. Kaldes hvert sekund af job-løkken. +function ADFields.scanNext(adEnv, farmId) + local owned = ownedFields(farmId) + local ids = {} + for _, entry in ipairs(owned) do ids[entry.id] = true end + for id in pairs(ADFields.cache) do + if not ids[id] then ADFields.cache[id] = nil end + end + ADFields.order = owned + if #owned == 0 then return end + ADFields.scanIndex = ADFields.scanIndex % #owned + 1 + local entry = owned[ADFields.scanIndex] + local ok, info = pcall(scanField, adEnv, entry.field, entry.id) + if ok then + ADFields.cache[entry.id] = info + else + Logging.warning("ADSmartPickup: F%s kunne ikke scannes: %s", tostring(entry.id), tostring(info)) + end +end + +function ADFields.list() + local infos = {} + for _, entry in ipairs(ADFields.order) do + local info = ADFields.cache[entry.id] + if info ~= nil then table.insert(infos, info) end + end + return infos +end + +function ADFields.getInfo(id) + return ADFields.cache[id] +end + +-- Siloer tæt på markens polygon (eller centrum): mellemlagre, som et udkørselsflow skal tømme hjem. +function ADFields.fieldSilos(adEnv, farmId, info) + local result = {} + if ADBuildings == nil or info == nil or info.x == nil then return result end + local anchors = #info.verts > 0 and info.verts or {{x = info.x, z = info.z}} + for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do + local node = building.kind == "silo" and building.placeable ~= nil and building.placeable.rootNode or nil + if node ~= nil then + local bx, _, bz = getWorldTranslation(node) + for _, anchor in ipairs(anchors) do + if (anchor.x - bx) ^ 2 + (anchor.z - bz) ^ 2 <= ADFields.FIELD_SILO_DISTANCE ^ 2 then + table.insert(result, building) + break + end + end + end + end + return result +end +``` + +- [ ] **Step 4: Run test + syntax check** + +Run: `luajit tests/test_adFields.lua && luajit -b FS25_ADSmartPickup/adFields.lua /dev/null` +Expected: `ALLE TESTS OK`, ingen syntaksfejl + +- [ ] **Step 5: Commit** + +```bash +git add FS25_ADSmartPickup/adFields.lua tests/test_adFields.lua +git commit -m "feat(marker): marker fra spillet — slåafgrøde, klar-andel, F-markør, marksiloer" +``` + +--- + +### Task 5: Courseplay-bro (`adCpBridge.lua`) + +**Files:** +- Create: `FS25_ADSmartPickup/adCpBridge.lua` + +**Interfaces:** +- Produces: + - `ADCpBridge.isAvailable() -> bool` + - `ADCpBridge.start(vehicle, fieldX, fieldZ, onDone) -> handle|nil, err` — `onDone(ok, reason)` kaldes én gang; `handle.cancelled = true` annullerer sene callbacks + - `ADCpBridge.isWorking(vehicle) -> bool` (CP-markarbejde eller anden AI aktiv) + - `ADCpBridge.getWorkWidth(vehicle) -> meter` (max over køretøj + redskaber, `getAIMarkers` 5. retur) + - `ADCpBridge.getSpecs(vehicle) -> {spec_mower = true, ...}` (hele redskabskæden) + +- [ ] **Step 1: Write implementation** (kun i spillet; mønster bevist i AI-Bonden `AIHelperGlue.startCP`) + +File: FS25_ADSmartPickup/adCpBridge.lua +```lua +-- AD Courseplay Bridge +-- Den eneste kode der rører Courseplay: start markarbejde på en mark (grænse -> kursus -> start, alt +-- async), og læs om et sæt arbejder. Opskriften er bevist i spillet i AI-Bonden (AIHelperGlue.startCP): +-- CP kalder grænse-callbacken to gange (først false ved fejl) — kun første kald tæller. + +ADCpBridge = {} + +ADCpBridge.SPECS = {"spec_mower", "spec_tedder", "spec_windrower", "spec_forageWagon"} + +function ADCpBridge.isAvailable() + return AIJobType ~= nil and AIJobType.FIELDWORK_CP ~= nil +end + +local function collect(vehicle, list) + list = list or {} + table.insert(list, vehicle) + if vehicle.getAttachedImplements ~= nil then + for _, implement in ipairs(vehicle:getAttachedImplements()) do + if implement.object ~= nil then collect(implement.object, list) end + end + end + return list +end + +function ADCpBridge.getSpecs(vehicle) + local specs = {} + for _, object in ipairs(collect(vehicle)) do + for _, name in ipairs(ADCpBridge.SPECS) do + if object[name] ~= nil then specs[name] = true end + end + end + return specs +end + +function ADCpBridge.getWorkWidth(vehicle) + local best = 0 + for _, object in ipairs(collect(vehicle)) do + if object.getAIMarkers ~= nil then + pcall(function() if object.updateAIMarkerWidth ~= nil then object:updateAIMarkerWidth() end end) + local ok, _, _, _, _, width = pcall(object.getAIMarkers, object) + if ok and type(width) == "number" and width > best then best = width end + end + end + return best +end + +function ADCpBridge.isWorking(vehicle) + if vehicle.getIsCpFieldWorkActive ~= nil and vehicle:getIsCpFieldWorkActive() then return true end + return vehicle.getIsAIActive ~= nil and vehicle:getIsAIActive() or false +end + +function ADCpBridge.start(vehicle, fieldX, fieldZ, onDone) + if not ADCpBridge.isAvailable() then + return nil, "Courseplay (FIELDWORK_CP) findes ikke" + end + local farmId = vehicle:getOwnerFarmId() + local job = g_currentMission.aiJobTypeManager:createJob(AIJobType.FIELDWORK_CP) + if job == nil then return nil, "kunne ikke oprette Courseplay-job" end + job:applyCurrentState(vehicle, g_currentMission, farmId, false, false) + local vx, _, vz = getWorldTranslation(vehicle.rootNode) + local params = job.cpJobParameters + if params ~= nil and params.fieldPosition ~= nil then params.fieldPosition:setPosition(fieldX, fieldZ) end + if params ~= nil and params.startPosition ~= nil then params.startPosition:setPosition(vx, vz) end + job:setValues() + local handle = {cancelled = false, fired = false, finished = false} + local function finish(ok, reason) + if handle.finished then return end + handle.finished = true + if not handle.cancelled then onDone(ok, reason) end + end + local function generateAndStart() + if handle.cancelled then return end + job:onClickGenerateFieldWorkCourse(function(course) + if handle.cancelled then return end + if not course then return finish(false, "Courseplay kunne ikke lave kursus") end + job:setValues() + local ok, err = job:validate(farmId) + if not ok then return finish(false, "Courseplay: " .. tostring(err)) end + g_currentMission.aiSystem:startJob(job, farmId) + finish(true) + end) + end + handle.onBoundary = function(_, success, message) + if handle.fired then return end + handle.fired = true + if handle.cancelled then return end + if not success then return finish(false, "Courseplay fandt ikke markgrænsen: " .. tostring(message)) end + generateAndStart() + end + job:registerFieldBoundaryDetectionCallback(handle, handle.onBoundary) + local isValid, isRunning, message = job:detectFieldBoundary() + if isValid and not isRunning then + generateAndStart() + elseif not isRunning then + return nil, "Courseplay: " .. tostring(message) + end + return handle +end +``` + +- [ ] **Step 2: Syntax check** + +Run: `luajit -b FS25_ADSmartPickup/adCpBridge.lua /dev/null` +Expected: ingen output + +- [ ] **Step 3: Commit** + +```bash +git add FS25_ADSmartPickup/adCpBridge.lua +git commit -m "feat(marker): Courseplay-bro — start markarbejde async, bredde og redskabs-specs" +``` + +--- + +### Task 6: Job-løkke, lagring og indbinding + +**Files:** +- Create: `FS25_ADSmartPickup/adFieldStorage.lua` +- Create: `FS25_ADSmartPickup/adFieldJobs.lua` +- Modify: `FS25_ADSmartPickup/modDesc.xml` (sourceFiles før `adSmartPickup.lua`) +- Modify: `FS25_ADSmartPickup/adSmartPickup.lua` (`update`: load + tick; gem via `ADRunsController.save`) +- Modify: `FS25_ADSmartPickup/adRunsController.lua` (`save()` gemmer også marker) +- Test: `tests/test_adFieldJobs.lua` (rene dele: `ADFieldJobs.decide`) + +**Interfaces:** +- Consumes: Task 1–5 +- Produces: + - `ADFieldJobs.state = {fields = {[id] = field}, fleet = {[vehicleId] = true}, assignments = {[vehicleId] = assignment}}` + - `ADFieldJobs.decide(field, info, assignments, now) -> {action = "start"|"advance"|"wait"|"none", role}` (ren) + - `ADFieldJobs.tick(adEnv, dt)`; `ADFieldJobs.requestStart(id)`; `ADFieldJobs.stopField(adEnv, id)`; `ADFieldJobs.setPlan(id, changes)`; `ADFieldJobs.toggleFleet(vehicleId)`; `ADFieldJobs.getRigs(adEnv) -> candidates`; `ADFieldJobs.getField(id)`; `ADFieldJobs.assignmentsFor(id) -> list` + - `ADFieldStorage.save(state)`, `ADFieldStorage.load() -> {fields, fleet}` +- `assignment = {vehicleId, fieldId, role, phase = "driving"|"starting"|"working"|"returning", since, handle}` + +- [ ] **Step 1: Write the failing test** + +File: tests/test_adFieldJobs.lua +```lua +-- Kør: luajit tests/test_adFieldJobs.lua (fra repo-roden) +dofile("FS25_ADSmartPickup/adFieldModel.lua") +dofile("FS25_ADSmartPickup/adFieldJobs.lua") +local J = ADFieldJobs +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 +local info = {id = 109, markerId = 5, mowable = true, readyRatio = 1} + +-- J1: klar auto-mark med markør -> start; uden markør -> vent +local auto = M.onReadiness(M.newField(109), 1); auto.plan.mode = "auto" +check("J1 start", J.decide(auto, info, {}, 0).action, "start") +check("J1 uden markør", J.decide(auto, {id = 109, mowable = true}, {}, 0).action, "wait") + +-- J2: arbejdende trin uden sæt -> send rolle; alle sæt færdige -> næste trin +local mowing = M.start(auto) +local wanted = J.decide(mowing, info, {}, 0) +check("J2 send slåmaskine", wanted.action, "dispatch"); check("J2 rolle", wanted.role, "mower") +check("J2 sæt i gang -> ingenting", J.decide(mowing, info, {{phase = "working"}}, 0).action, "none") +check("J2 færdig -> videre", J.decide(mowing, info, {{phase = "finished"}}, 0).action, "advance") +-- J3: sæt frigivet (spilleren tog det) tæller ikke som færdigt +check("J3 frigivet -> send nyt", J.decide(mowing, info, {{phase = "released"}}, 0).action, "dispatch") +-- J4: slåning færdig men marken stadig klar -> ikke videre (afbrudt) +check("J4 afbrudt slåning", J.decide(mowing, {id = 109, markerId = 5, mowable = true, readyRatio = 0.8}, {{phase = "finished"}}, 0).action, "dispatch") +-- J5: opsamling efter genindlæsning uden sæt -> send læssevogn +local collecting = M.advance(mowing) +check("J5 genoptag opsamling", J.decide(collecting, info, {}, 0).role, "collector") +check("J5 to vogne maks", J.decide(collecting, info, {{phase = "working"}, {phase = "driving"}}, 0).action, "none") + +print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) +os.exit(failures == 0 and 0 or 1) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `luajit tests/test_adFieldJobs.lua` +Expected: FAIL — `cannot open FS25_ADSmartPickup/adFieldJobs.lua` + +- [ ] **Step 3: Write storage** + +File: FS25_ADSmartPickup/adFieldStorage.lua +```lua +-- AD Field Storage +-- Markernes trin/plan og markflåden gemmes i savegame-mappen ved siden af runs. + +ADFieldStorage = {} + +ADFieldStorage.FILE_NAME = "ADSmartPickup_fields.xml" +ADFieldStorage.ROOT = "ADSmartPickupFields" + +function ADFieldStorage.getPath() + local runsPath = ADRunsStorage ~= nil and ADRunsStorage.getPath() or nil + if runsPath == nil then return nil end + return (string.gsub(runsPath, ADRunsStorage.FILE_NAME .. "$", ADFieldStorage.FILE_NAME)) +end + +function ADFieldStorage.save(state) + local path = ADFieldStorage.getPath() + if path == nil then return false end + local xmlFile = XMLFile.create("ADSmartPickupFields", path, ADFieldStorage.ROOT) + if xmlFile == nil then return false end + local index = 0 + for _, field in pairs(state.fields) do + local record = ADFieldModel.toRecord(field) + local key = string.format("%s.field(%d)", ADFieldStorage.ROOT, index) + xmlFile:setInt(key .. "#id", record.id) + xmlFile:setString(key .. "#stage", record.stage) + xmlFile:setString(key .. "#mode", record.mode) + xmlFile:setString(key .. "#product", record.product) + xmlFile:setInt(key .. "#rake", record.rake) + xmlFile:setString(key .. "#store", record.store) + xmlFile:setFloat(key .. "#mowWidth", record.mowWidth) + index = index + 1 + end + local vehicleIndex = 0 + for vehicleId in pairs(state.fleet) do + xmlFile:setString(string.format("%s.fleet.vehicle(%d)#id", ADFieldStorage.ROOT, vehicleIndex), vehicleId) + vehicleIndex = vehicleIndex + 1 + end + xmlFile:save() + xmlFile:delete() + return true +end + +function ADFieldStorage.load() + local result = {fields = {}, fleet = {}} + local path = ADFieldStorage.getPath() + if path == nil or not fileExists(path) then return result end + local xmlFile = XMLFile.load("ADSmartPickupFields", path) + if xmlFile == nil then return result end + xmlFile:iterate(ADFieldStorage.ROOT .. ".field", function(_, key) + local field = ADFieldModel.fromRecord({ + id = xmlFile:getInt(key .. "#id"), stage = xmlFile:getString(key .. "#stage"), + mode = xmlFile:getString(key .. "#mode"), product = xmlFile:getString(key .. "#product"), + rake = xmlFile:getInt(key .. "#rake"), store = xmlFile:getString(key .. "#store"), + mowWidth = xmlFile:getFloat(key .. "#mowWidth"), + }) + if field.id ~= nil then result.fields[field.id] = field end + end) + xmlFile:iterate(ADFieldStorage.ROOT .. ".fleet.vehicle", function(_, key) + local id = xmlFile:getString(key .. "#id") + if id ~= nil and id ~= "" then result.fleet[id] = true end + end) + xmlFile:delete() + return result +end +``` + +- [ ] **Step 4: Write the job loop** + +File: FS25_ADSmartPickup/adFieldJobs.lua +```lua +-- AD Field Jobs +-- Markarbejdets job-løkke (hvert sekund): opdater klar-andel, start marker, send sæt ud, følg dem +-- gennem AutoDrive-kørsel -> Courseplay-arbejde -> hjem, og gå videre til næste trin. decide() er ren +-- og testet; resten oversætter til AutoDrive/Courseplay og er pcall'et pr. mark og pr. sæt. + +ADFieldJobs = {} + +ADFieldJobs.LOG_PREFIX = "ADSmartPickup: " +ADFieldJobs.TICK_MS = 1000 +ADFieldJobs.ARRIVE_DISTANCE = 30 +ADFieldJobs.IDLE_CONFIRM_MS = 5000 +ADFieldJobs.state = {fields = {}, fleet = {}, assignments = {}} +ADFieldJobs.timer = 0 +ADFieldJobs.now = 0 +ADFieldJobs.loaded = false + +local ACTIVE_PHASES = {driving = true, starting = true, working = true} + +-- ---- ren beslutning ---- + +function ADFieldJobs.decide(field, info, assignments, now) + if field.stage == "ready" then + if not ADFieldModel.shouldStart(field) then return {action = "none"} end + if info == nil or info.markerId == nil then return {action = "wait"} end + return {action = "start"} + end + if not ADFieldModel.isWorking(field) then return {action = "none"} end + local active, finished = 0, 0 + for _, assignment in ipairs(assignments) do + if ACTIVE_PHASES[assignment.phase] then active = active + 1 end + if assignment.phase == "finished" or assignment.phase == "returning" then finished = finished + 1 end + end + if active == 0 and finished > 0 then + local stillStanding = field.stage == "mowing" and info ~= nil and (info.readyRatio or 0) >= ADFieldModel.CUT_RATIO + if not stillStanding then return {action = "advance"} end + end + if info == nil or info.markerId == nil then return {action = "wait"} end + local role = ADFieldModel.wantsRig(field, active, now) + if role ~= nil then return {action = "dispatch", role = role} end + return {action = "none"} +end + +-- ---- tilstand ---- + +local function log(fieldId, fmt, ...) + Logging.info(ADFieldJobs.LOG_PREFIX .. "F%s " .. fmt, tostring(fieldId), ...) +end + +function ADFieldJobs.getField(id) + return ADFieldJobs.state.fields[id] or ADFieldModel.newField(id) +end + +local function setField(field) + ADFieldJobs.state.fields[field.id] = field +end + +function ADFieldJobs.assignmentsFor(fieldId) + local list = {} + for _, assignment in pairs(ADFieldJobs.state.assignments) do + if assignment.fieldId == fieldId then table.insert(list, assignment) end + end + return list +end + +function ADFieldJobs.save() + local ok, err = pcall(ADFieldStorage.save, ADFieldJobs.state) + if not ok then Logging.warning(ADFieldJobs.LOG_PREFIX .. "marker kunne ikke gemmes: %s", tostring(err)) end +end + +function ADFieldJobs.load() + local ok, loaded = pcall(ADFieldStorage.load) + if ok then + ADFieldJobs.state.fields, ADFieldJobs.state.fleet = loaded.fields, loaded.fleet + else + Logging.warning(ADFieldJobs.LOG_PREFIX .. "marker kunne ikke indlæses: %s", tostring(loaded)) + end + ADFieldJobs.loaded = true +end + +function ADFieldJobs.setPlan(id, changes) + setField(ADFieldModel.with(ADFieldJobs.getField(id), {plan = changes})) + ADFieldJobs.save() +end + +function ADFieldJobs.requestStart(id) + setField(ADFieldModel.with(ADFieldJobs.getField(id), {startRequested = true})) +end + +local function note(fieldId, changes) + setField(ADFieldModel.with(ADFieldJobs.getField(fieldId), changes)) +end + +function ADFieldJobs.toggleFleet(vehicleId) + ADFieldJobs.state.fleet = ADFieldFleet.toggle(ADFieldJobs.state.fleet, vehicleId) + ADFieldJobs.save() +end + +-- ---- AutoDrive ---- + +local function stateModule(vehicle) + return vehicle.ad ~= nil and vehicle.ad.stateModule or nil +end + +local function isAdActive(vehicle) + local module = stateModule(vehicle) + return module ~= nil and module:isActive() +end + +local function stopAd(vehicle) + if isAdActive(vehicle) then vehicle:stopAutoDrive() end +end + +local function startAd(adEnv, vehicle, mode, firstId, secondId, withCp) + local module = stateModule(vehicle) + stopAd(vehicle) + module:setMode(mode) + module:setFirstMarkerByWayPointId(firstId) + if secondId ~= nil then module:setSecondMarkerByWayPointId(secondId) end + module:setStartHelper(withCp == true) + if withCp then + module.usedHelper = adEnv.ADStateModule ~= nil and adEnv.ADStateModule.HELPER_CP or 1 + if vehicle.ad.settings ~= nil and vehicle.ad.settings.enableParkAtJobFinished ~= nil then + vehicle.ad.settings.enableParkAtJobFinished.current = 2 + vehicle.ad.settings.enableParkAtJobFinished.new = 2 + end + end + local farmId = vehicle:getOwnerFarmId() + if module.setActualFarmId ~= nil then module:setActualFarmId(farmId) end + module:raiseDirtyFlag() + local current = module:getCurrentMode() + if current ~= nil and current.start ~= nil then + current:start(adEnv.AutoDrive.USER_PLAYER or 1) + else + vehicle:startAutoDrive() + end +end + +local function parkMarkerId(adEnv, vehicle) + local module = stateModule(vehicle) + local index = module ~= nil and module.getParkDestinationAtJobFinished ~= nil and module:getParkDestinationAtJobFinished() or nil + if index == nil or index < 1 then return nil end + local marker = adEnv.ADGraphManager:getMapMarkerById(index) + return marker ~= nil and marker.id or nil +end + +local function restLoad(adEnv, vehicle) + local ok, level = pcall(function() + return (adEnv.AutoDrive.getAllFillLevels(adEnv.AutoDrive.getAllUnits(vehicle))) + end) + return ok and tonumber(level) or 0 +end + +local function vehiclePosition(vehicle) + local x, _, z = getWorldTranslation(vehicle.rootNode) + return x, z +end + +local function isControlledByPlayer(vehicle) + return (vehicle.getIsControlled ~= nil and vehicle:getIsControlled()) or false +end + +-- ---- flåde ---- + +function ADFieldJobs.getRigs(adEnv) + local rigs = {} + for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do + local role = ADFieldFleet.roleFromSpecs(ADCpBridge.getSpecs(vehicle)) + local id = ADRunsController.getVehicleId(vehicle) + if role ~= nil and id ~= nil then + local x, z = vehiclePosition(vehicle) + table.insert(rigs, { + id = id, vehicle = vehicle, role = role, label = ADRuns.vehicleLabel(vehicle), + enabled = ADFieldJobs.state.fleet[id] == true, + busy = ADFieldJobs.state.assignments[id] ~= nil or ADRunsController.getRunForVehicle(vehicle) ~= nil, + controlled = isControlledByPlayer(vehicle), + width = ADCpBridge.getWorkWidth(vehicle), x = x, z = z, + }) + end + end + return rigs +end + +-- ---- silo ---- + +local function chooseStore(adEnv, field, info, vehicle) + local fillType = field.plan.product == "hay" and info.hay or info.windrow + local isReachable = function(markerId) return ADUnloadWait.hasRouteTo(adEnv, vehicle, markerId) end + if #field.plan.storeBuildingIds > 0 then + local markerIds = {} + for _, buildingId in ipairs(field.plan.storeBuildingIds) do + local building = ADBuildings.find(adEnv, vehicle:getOwnerFarmId(), buildingId) + for _, marker in ipairs(building ~= nil and ADBuildings.getMarkers(building, "unload") or {}) do + table.insert(markerIds, marker.id) + end + end + return ADLoadSwap.findForcedDumpMarker(adEnv, vehicle, fillType, 1, markerIds, isReachable) + end + return ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, 1, info.markerId, isReachable, nil, nil, false) +end + +-- ---- udsendelse og opfølgning ---- + +local function release(assignment, phase) + assignment.phase = phase or "released" + ADFieldJobs.state.assignments[assignment.vehicleId] = nil +end + +local function dispatch(adEnv, field, info, role) + local candidates = ADFieldJobs.getRigs(adEnv) + local rig, reason, args = ADFieldFleet.pick(candidates, role, info.x, info.z, field.mowWidth) + if rig == nil then + note(field.id, {reason = {key = "spu_field_reason_" .. reason, role = role, args = args}}) + return + end + local assignment = {vehicleId = rig.id, vehicle = rig.vehicle, fieldId = field.id, role = role, phase = "driving", since = ADFieldJobs.now, width = rig.width} + ADFieldJobs.state.assignments[rig.id] = assignment + startAd(adEnv, rig.vehicle, adEnv.AutoDrive.MODE_DRIVETO, info.markerId, nil, false) + log(field.id, "%s '%s' sendt til %s (%.1f m)", role, rig.label, tostring(info.markerName), rig.width) + note(field.id, {reason = false}) +end + +local function beginWork(adEnv, field, info, assignment) + local vehicle = assignment.vehicle + if assignment.role == "collector" then + local store, why = chooseStore(adEnv, field, info, vehicle) + if store == nil then + release(assignment) + note(field.id, {retryAt = ADFieldJobs.now + ADFieldModel.RETRY_MS, reason = {text = tostring(why)}}) + log(field.id, "intet lager: %s", tostring(why)) + return + end + assignment.storeId = store.id + -- AD overtager ved "fuld": Hent og aflever F -> silo, hjælper = Courseplay + local module = stateModule(vehicle) + module:setMode(adEnv.AutoDrive.MODE_PICKUPANDDELIVER) + module:setFirstMarkerByWayPointId(info.markerId) + module:setSecondMarkerByWayPointId(store.id) + module:setStartHelper(true) + module.usedHelper = adEnv.ADStateModule ~= nil and adEnv.ADStateModule.HELPER_CP or 1 + if vehicle.ad.settings ~= nil and vehicle.ad.settings.enableParkAtJobFinished ~= nil then + vehicle.ad.settings.enableParkAtJobFinished.current = 2 + vehicle.ad.settings.enableParkAtJobFinished.new = 2 + end + module:raiseDirtyFlag() + log(field.id, "læssevogn '%s' aflæsser i %s", ADRuns.vehicleLabel(vehicle), tostring(store.name)) + end + local point = info.points[1] or {x = info.x, z = info.z} + assignment.phase = "starting" + assignment.since = ADFieldJobs.now + local handle, err = ADCpBridge.start(vehicle, point.x, point.z, function(ok, reason) + if ok then + assignment.phase = "working" + assignment.since = ADFieldJobs.now + if assignment.role == "mower" then + note(field.id, {mowWidth = assignment.width}) + end + log(field.id, "Courseplay startet (%s)", assignment.role) + else + release(assignment) + note(field.id, {retryAt = ADFieldJobs.now + ADFieldModel.RETRY_MS, reason = {text = tostring(reason)}}) + log(field.id, "Courseplay fejlede: %s", tostring(reason)) + end + end) + if handle == nil then + release(assignment) + log(field.id, "Courseplay kunne ikke startes: %s", tostring(err)) + note(field.id, {retryAt = ADFieldJobs.now + ADFieldModel.RETRY_MS, reason = {text = tostring(err)}}) + return + end + assignment.handle = handle +end + +local function follow(adEnv, assignment) + local vehicle = assignment.vehicle + if isControlledByPlayer(vehicle) then + if assignment.handle ~= nil then assignment.handle.cancelled = true end + log(assignment.fieldId, "'%s' frigivet — spilleren kører det", ADRuns.vehicleLabel(vehicle)) + return release(assignment) + end + local field = ADFieldJobs.getField(assignment.fieldId) + local info = ADFields.getInfo(assignment.fieldId) + if assignment.phase == "driving" then + if not isAdActive(vehicle) and ADFieldJobs.now - assignment.since > 2000 then + local x, z = vehiclePosition(vehicle) + local wayPoint = adEnv.ADGraphManager:getWayPointById(info.markerId) + local distance = wayPoint ~= nil and math.sqrt((wayPoint.x - x) ^ 2 + (wayPoint.z - z) ^ 2) or math.huge + if distance <= ADFieldJobs.ARRIVE_DISTANCE then + beginWork(adEnv, field, info, assignment) + else + log(assignment.fieldId, "'%s' nåede ikke %s (%.0f m væk)", ADRuns.vehicleLabel(vehicle), tostring(info.markerName), distance) + release(assignment) + end + end + elseif assignment.phase == "working" then + local busy = ADCpBridge.isWorking(vehicle) or isAdActive(vehicle) + if busy then + assignment.idleSince = nil + else + assignment.idleSince = assignment.idleSince or ADFieldJobs.now + local rest = assignment.role == "collector" and assignment.storeId ~= nil and not assignment.deliveredRest and restLoad(adEnv, vehicle) or 0 + if rest > 0 and ADFieldJobs.now - assignment.idleSince >= ADFieldJobs.IDLE_CONFIRM_MS then + -- CP færdig med læs på: AD tømmer kun selv med parkering sat, så vi sender resten til silo + assignment.deliveredRest = true + assignment.idleSince = nil + startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DELIVERTO, assignment.storeId, nil, false) + log(assignment.fieldId, "'%s' kører rest-læs (%.0f l) til silo", ADRuns.vehicleLabel(vehicle), rest) + elseif ADFieldJobs.now - assignment.idleSince >= ADFieldJobs.IDLE_CONFIRM_MS then + log(assignment.fieldId, "%s '%s' færdig", assignment.role, ADRuns.vehicleLabel(vehicle)) + local park = assignment.role ~= "collector" and parkMarkerId(adEnv, vehicle) or nil + if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil, false) end + assignment.phase = "finished" + ADFieldJobs.finished = ADFieldJobs.finished or {} + table.insert(ADFieldJobs.finished, assignment) + ADFieldJobs.state.assignments[assignment.vehicleId] = nil + end + end + end +end + +function ADFieldJobs.stopField(adEnv, id) + for _, assignment in ipairs(ADFieldJobs.assignmentsFor(id)) do + if assignment.handle ~= nil then assignment.handle.cancelled = true end + pcall(stopAd, assignment.vehicle) + pcall(function() if assignment.vehicle.stopCurrentAIJob ~= nil and ADCpBridge.isWorking(assignment.vehicle) then assignment.vehicle:stopCurrentAIJob(AIMessageSuccessStoppedByUser.new()) end end) + release(assignment) + end + setField(ADFieldModel.stop(ADFieldJobs.getField(id))) + ADFieldJobs.save() + log(id, "stoppet fra menuen") +end + +local function finishedFor(fieldId) + local list = {} + for _, assignment in ipairs(ADFieldJobs.finished or {}) do + if assignment.fieldId == fieldId then table.insert(list, assignment) end + end + return list +end + +local function stepField(adEnv, info) + local field = ADFieldModel.onReadiness(ADFieldJobs.getField(info.id), info.readyRatio or 0) + setField(field) + local assignments = ADFieldJobs.assignmentsFor(info.id) + for _, done in ipairs(finishedFor(info.id)) do table.insert(assignments, done) end + local decision = ADFieldJobs.decide(field, info, assignments, ADFieldJobs.now) + if decision.action == "start" then + setField(ADFieldModel.start(field)) + log(info.id, "starter (%s)", field.plan.product) + ADFieldJobs.save() + elseif decision.action == "advance" then + local nextField = ADFieldModel.advance(field) + setField(nextField) + local remaining = {} + for _, done in ipairs(ADFieldJobs.finished or {}) do + if done.fieldId ~= info.id then table.insert(remaining, done) end + end + ADFieldJobs.finished = remaining + log(info.id, "%s -> %s", field.stage, nextField.stage) + ADFieldJobs.save() + elseif decision.action == "dispatch" then + dispatch(adEnv, field, info, decision.role) + end +end + +function ADFieldJobs.tick(adEnv, dt) + ADFieldJobs.now = ADFieldJobs.now + dt + ADFieldJobs.timer = ADFieldJobs.timer + dt + if ADFieldJobs.timer < ADFieldJobs.TICK_MS then return end + ADFieldJobs.timer = 0 + if not ADFieldJobs.loaded then ADFieldJobs.load() end + local farmId = g_currentMission:getFarmId() + ADFields.scanNext(adEnv, farmId) + for _, assignment in pairs(ADFieldJobs.state.assignments) do + local ok, err = pcall(follow, adEnv, assignment) + if not ok then + Logging.warning(ADFieldJobs.LOG_PREFIX .. "F%s opfølgning fejlede: %s", tostring(assignment.fieldId), tostring(err)) + release(assignment) + end + end + for _, info in ipairs(ADFields.list()) do + if info.mowable or ADFieldModel.isWorking(ADFieldJobs.getField(info.id)) then + local ok, err = pcall(stepField, adEnv, info) + if not ok then Logging.warning(ADFieldJobs.LOG_PREFIX .. "F%s fejl: %s", tostring(info.id), tostring(err)) end + end + end +end +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `luajit tests/test_adFieldJobs.lua && luajit -b FS25_ADSmartPickup/adFieldJobs.lua /dev/null && luajit -b FS25_ADSmartPickup/adFieldStorage.lua /dev/null` +Expected: `ALLE TESTS OK` + +- [ ] **Step 6: Wire into the mod** + +`modDesc.xml` — indsæt før ``: +```xml + + + + + + + +``` + +`adSmartPickup.lua` — i `ADSmartPickup:update(dt)` efter `applyVehicleNamesThrottled`-kaldet: +```lua + if ADFieldJobs ~= nil then + local okFields, errFields = pcall(ADFieldJobs.tick, getAutoDriveEnv(), dt) + if not okFields then + Logging.warning(ADSmartPickup.LOG_PREFIX .. "markarbejde fejlede: %s", tostring(errFields)) + end + end +``` + +`adRunsController.lua` — i `ADRunsController.save()` før `return saved`: +```lua + if ADFieldJobs ~= nil and ADFieldJobs.loaded then + pcall(ADFieldJobs.save) + end +``` + +- [ ] **Step 7: Run all tests + commit** + +Run: `for t in tests/test_*.lua; do luajit $t >/dev/null || echo "FEJL $t"; done` +Expected: ingen FEJL-linjer + +```bash +git add FS25_ADSmartPickup tests/test_adFieldJobs.lua +git commit -m "feat(marker): job-løkke — AutoDrive til F-markør, Courseplay på marken, næste trin, gem" +``` + +--- + +### Task 7: Fanen Marker + +**Files:** +- Create: `FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua` +- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.lua` (fane-indeks, rebuild-gren, knapper, klik) +- Modify: `FS25_ADSmartPickup/gui/SmartPickupFrame.xml` (prefab `fieldPrefab`) +- Modify: `FS25_ADSmartPickup/gui/SmartPickupFlowTab.lua` (`makeRowClickable` tager callback) +- Modify: `FS25_ADSmartPickup/adSmartPickup.lua` (source af fanen) +- Modify: `FS25_ADSmartPickup/l10n/l10n_da.xml`, `l10n_en.xml` + +**Interfaces:** +- Consumes: `ADFieldJobs.*`, `ADFields.list/getInfo/fieldSilos`, `ADFieldView.*`, `ADFieldModel.workStages` +- Produces: `SmartPickupFrame.TAB_FIELDS = 3`; `SmartPickupFrame.TAB_KINDS = {[4] = "factory", [5] = "silo", [6] = "husbandry", [7] = "objectStorage"}`; `SmartPickupFrame:fillFieldRows(adEnv)`, `refreshFieldRows(adEnv)`, `onClickFieldToggle(kind, isOn, data)` + +Udførelsen følger de eksisterende mønstre i `SmartPickupFlowTab.lua` (addCellRow, addToggleRow, makeRowClickable, +pendingJump via `update()`), med denne opførsel: + +- Tabel (`fieldPrefab`, 6 kolonner: Mark 20/100 · Afgrøde 120/130 · Plan 250/110 · Auto 360/80 · Status 440/230 · Maskiner 670/140): række pr. `ADFieldView.rows(ADFields.list(), ADFieldJobs.state.fields, self.showAllFields)`, "!" foran marknummer uden markør; klik → `self.selectedFieldId`. +- *Vis alle marker* (toggle-række) kun når `hiddenCount > 0` eller slået til. +- Detalje for valgt mark: Plan (Ensilage/Hø), Kørsel (Manuel/Auto), Riv (Nej/Ja, kun ved hø), Tving lager (ingredientPrefab-rækker med kind `fieldStore` for silo-bygninger der tager markens skår/hø), status med grund, markør + afstand, slåbredde, marksiloer uden udkørselsflow ("!"). +- Markflåde: vehiclePrefab-række pr. `ADFieldJobs.getRigs(adEnv)` (kind `fleetVehicle`), titel "Label — rolle, bredde m" + "!" over 50 m. +- Knapper på fanen: MENU_ACTIVATE = Start nu/Stop for valgt mark; MENU_EXTRA_1 = Nyt run (som Oversigt). + +- [ ] **Step 1: Write the tab, prefab, l10n, wiring** (se opførsel ovenfor) +- [ ] **Step 2: Syntax + XML check** + +Run: `for f in FS25_ADSmartPickup/gui/*.lua; do luajit -b $f /dev/null; done; python3 -c "import xml.dom.minidom as m;[m.parse(p) for p in ['FS25_ADSmartPickup/gui/SmartPickupFrame.xml','FS25_ADSmartPickup/l10n/l10n_da.xml','FS25_ADSmartPickup/l10n/l10n_en.xml']]"` +Expected: ingen output + +- [ ] **Step 3: Run all tests + commit** + +```bash +git add FS25_ADSmartPickup +git commit -m "feat(marker): fanen Marker — tabel, detalje, markflåde, Start nu/Stop" +``` + +--- + +### Task 8: Manual, version, build, deploy + +**Files:** +- Modify: `docs/BRUGERMANUAL.md`, `docs/USER_MANUAL.md` (nyt afsnit "Markarbejde (græs, kløver, lucerne)" + fane-tabel) +- Modify: `FS25_ADSmartPickup/modDesc.xml` (version `1.21.0.0`) + +- [ ] **Step 1:** Skriv manual-afsnit (opsætning: F-markører, markflåde, bredder, plan, Auto/Manuel, hvad status betyder, testprotokol). +- [ ] **Step 2:** `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/` +- [ ] **Step 3:** Commit, merge til `main`, push til Gitea.