70 KiB
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>(fxF109, ogsåf109,F 109). - Courseplay-maksbredde:
ADFieldModel.CP_MAX_WIDTH = 50m. 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 = 150m. - 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 = {}), ikkelocalpå modulniveau for ting andre filer skal bruge.
Review Focus
- 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.) - Mark uden
F-markør eller med markør langt væk → "!" og intet sendes. (Task 4 testF2, Task 3 testV3.) - 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.) - Genindlæsning midt i "Samles op" → marken fortsætter på samme trin, sæt sendes ud igen. (Task 1 test
M8, Task 6 testJ5.) - 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) -> fieldhvorfield = {id, stage, plan = {mode, product, rake, storeBuildingIds}, mowWidth, retryAt, reason, startRequested}ADFieldModel.workStages(plan) -> {"mowing", ...}ADFieldModel.roleFor(stage) -> "mower"|"tedder"|"rake"|"collector"|nilADFieldModel.maxRigs(stage) -> numberADFieldModel.onReadiness(field, ratio) -> field(ny tabel)ADFieldModel.shouldStart(field) -> bool,ADFieldModel.start(field) -> field,ADFieldModel.advance(field) -> field,ADFieldModel.stop(field) -> fieldADFieldModel.isWorking(field) -> boolADFieldModel.checkWidth(role, width, mowWidth) -> ok, reason, argsADFieldModel.wantsRig(field, assignedCount, now) -> role|nilADFieldModel.toRecord(field) -> record,ADFieldModel.fromRecord(record) -> fieldADFieldModel.with(field, changes) -> field(kopi med ændrede felter;changes.planflettes 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, argshvorcandidate = {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-felterADFieldModel -
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>ellerspu_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|nilADFields.buildMowable(mowerConverter, tedderConverter, fillTypeName) -> {[fruitIndex] = {windrow, hay}}ADFields.getMowable() -> samme tabel fra spillets konvertere (cachet)ADFields.list(adEnv, farmId) -> infosmedinfo = {id, field, x, z, points, mowable, fruitIndex, cropTitle, readyRatio, markerId, markerName, windrow, hay}; én mark scannes pr. kald tilADFields.scanNext()(round-robin), resten fra cacheADFields.getInfo(id) -> info|nilADFields.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() -> boolADCpBridge.start(vehicle, fieldX, fieldZ, onDone) -> handle|nil, err—onDone(ok, reason)kaldes én gang;handle.cancelled = trueannullerer sene callbacksADCpBridge.isWorking(vehicle) -> bool(CP-markarbejde eller anden AI aktiv)ADCpBridge.getWorkWidth(vehicle) -> meter(max over køretøj + redskaber,getAIMarkers5. 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øradSmartPickup.lua) - Modify:
FS25_ADSmartPickup/adSmartPickup.lua(update: load + tick; gem viaADRunsController.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) -> listADFieldStorage.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")
check("J2 færdig -> videre", J.decide(mowing, info, {{phase = "finished"}}, 0).action, "advance")
-- J3: sæt frigivet (spilleren tog det) tæller ikke som færdigt
check("J3 frigivet -> send nyt", J.decide(mowing, info, {{phase = "released"}}, 0).action, "dispatch")
-- J4: slåning færdig men marken stadig klar -> ikke videre (afbrudt)
check("J4 afbrudt slåning", J.decide(mowing, {id = 109, markerId = 5, mowable = true, readyRatio = 0.8}, {{phase = "finished"}}, 0).action, "dispatch")
-- J5: opsamling efter genindlæsning uden sæt -> send læssevogn
local collecting = M.advance(mowing)
check("J5 genoptag opsamling", J.decide(collecting, info, {}, 0).role, "collector")
check("J5 to vogne maks", J.decide(collecting, info, {{phase = "working"}, {phase = "driving"}}, 0).action, "none")
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
- Step 2: Run test to verify it fails
Run: luajit tests/test_adFieldJobs.lua
Expected: FAIL — cannot open FS25_ADSmartPickup/adFieldJobs.lua
- Step 3: Write storage
File: FS25_ADSmartPickup/adFieldStorage.lua
-- 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(prefabfieldPrefab) - Modify:
FS25_ADSmartPickup/gui/SmartPickupFlowTab.lua(makeRowClickabletager 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 > 0eller 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
fieldStorefor 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)(kindfleetVehicle), 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(version1.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.