Files
fs25-adsmartpickup/docs/superpowers/plans/2026-09-25-markarbejde-etape1.md
masterdraco 5c912c524e feat(marker): job-løkke — AutoDrive til F-markør, Courseplay på marken, næste trin, gem
Afvigelser fra planen: starting-timeout 60 s og pause efter 'nåede ikke markøren' (undgår
genudsendelse hvert sekund); J2-testen bruger en slået mark (readyRatio 0).
2026-09-25 21:50:30 +02:00

70 KiB
Raw Permalink Blame History

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<marknummer> (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<id> ….
  • 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

-- 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

-- 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
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

-- 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

-- 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
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_<stage> eller spu_field_noMarker)
    • ADFieldView.hiddenCount(infos, showAll) -> number
  • Step 1: Write the failing test

File: tests/test_adFieldView.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

-- 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
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

-- 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

-- 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
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

-- 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
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

-- 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

-- 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

-- 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 <sourceFile filename="adSmartPickup.lua"/>:

        <sourceFile filename="adFieldModel.lua"/>
        <sourceFile filename="adFieldFleet.lua"/>
        <sourceFile filename="adFieldView.lua"/>
        <sourceFile filename="adFields.lua"/>
        <sourceFile filename="adCpBridge.lua"/>
        <sourceFile filename="adFieldStorage.lua"/>
        <sourceFile filename="adFieldJobs.lua"/>

adSmartPickup.lua — i ADSmartPickup:update(dt) efter applyVehicleNamesThrottled-kaldet:

    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:

    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

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
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.