# 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") local mowedInfo = {id = 109, markerId = 5, mowable = true, readyRatio = 0} check("J2 færdig -> videre", J.decide(mowing, mowedInfo, {{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.