feat(markarbejde): genbrug Courseplay-kurser — markform-signatur + kursus-register pr. maskine; genbrug/kopi i stedet for ny beregning, glemmes ved ændret mark (v1.34.0.0)

This commit is contained in:
2026-09-26 22:27:16 +02:00
parent 39fa72ba0d
commit 50301b61b9
14 changed files with 675 additions and 6 deletions
+195
View File
@@ -0,0 +1,195 @@
-- Kør: luajit tests/test_adCourseCache.lua (fra repo-roden)
-- Kursus-registret: hvilken mark/bredde/markform/indstillinger en maskines nuværende Courseplay-kursus blev
-- lavet til. Genbrug kun når ALT matcher; egen maskine først, ellers en anden maskines kursus (kopi).
dofile("FS25_ADSmartPickup/adCourseCache.lua")
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 function count(map)
local n = 0
for _ in pairs(map) do n = n + 1 end
return n
end
local C = ADCourseCache
local SIG = "n=4;a=960000;c=800,300"
-- ---- indstillings-nøglen ----
check("K1 standard", C.settingsKey({}), "off=0.0;lane=-;multi=1;vine=-;skip=-")
check("K2 nil = standard", C.settingsKey(nil), C.settingsKey({}))
check("K3 alt sat", C.settingsKey({toolOffset = 1.25, lane = -1, multiTools = 2, vineOffset = -1, vineSkip = 1}),
"off=1.3;lane=-1;multi=2;vine=-1.0;skip=1")
check("K4 -0.0 = 0.0", C.settingsKey({toolOffset = -0.0001}), C.settingsKey({toolOffset = 0}))
check("K5 float-støj i forskydning tæller ikke", C.settingsKey({toolOffset = 1.2000001}), C.settingsKey({toolOffset = 1.2}))
check("K6 anden bane = anden nøgle", C.settingsKey({multiTools = 2, lane = 1}) ~= C.settingsKey({multiTools = 2, lane = -1}), true)
check("K7 vin-offset indgår", C.settingsKey({vineOffset = 0}) ~= C.settingsKey({vineOffset = -1}), true)
check("K8 vin-skip indgår", C.settingsKey({vineOffset = -1, vineSkip = 0}) ~= C.settingsKey({vineOffset = -1, vineSkip = 1}), true)
local settings = C.settingsKey({})
local request = {field = 22, width = 30, sig = SIG, settings = settings}
local own = C.newRecord({vehicle = "v1", field = 22, width = 30.0, sig = SIG, settings = settings, waypoints = 812})
-- ---- key/matches ----
check("M1 samme alt", C.matches(own, request), true)
check("M2 bredde som float32 fra XML (30.0000001)", C.matches(C.newRecord({vehicle = "v1", field = 22, width = 30.0000001, sig = SIG, settings = settings}), request), true)
check("M3 ny bredde", C.matches(own, {field = 22, width = 12, sig = SIG, settings = settings}), false)
check("M4 anden mark", C.matches(own, {field = 23, width = 30, sig = SIG, settings = settings}), false)
check("M5 form ændret", C.matches(own, {field = 22, width = 30, sig = "n=5;a=960000;c=800,300", settings = settings}), false)
check("M6 andre indstillinger", C.matches(own, {field = 22, width = 30, sig = SIG, settings = C.settingsKey({toolOffset = 2})}), false)
check("M7 ukendt form (nil) matcher aldrig", C.matches(C.newRecord({vehicle = "v1", field = 22, width = 30, settings = settings}), {field = 22, width = 30, settings = settings}), false)
check("M8 intet register", C.matches(nil, request), false)
check("M9 key", C.key(request), "22|30.0|" .. SIG .. "|" .. settings)
check("M10 key uden form = nil", C.key({field = 22, width = 30, settings = settings}), nil)
-- ---- grund til ny beregning ----
check("R1 intet gemt", C.mismatchReason(nil, request), "intet kursus gemt")
check("R2 form ændret", C.mismatchReason(own, {field = 22, width = 30, sig = "x", settings = settings}), "markens form ændret")
check("R3 ny bredde", C.mismatchReason(own, {field = 22, width = 12, sig = SIG, settings = settings}), "ny bredde")
check("R4 anden mark", C.mismatchReason(own, {field = 9, width = 30, sig = SIG, settings = settings}), "kursus til en anden mark")
check("R5 indstillinger", C.mismatchReason(own, {field = 22, width = 30, sig = SIG, settings = "x"}), "andre indstillinger")
check("R6 ukendt form", C.mismatchReason(own, {field = 22, width = 30, settings = settings}), "markens form ukendt")
-- ---- register: immutabelt ----
local records = C.withRecord({}, own)
check("I1 tilføjet", records.v1 ~= nil, true)
local other = C.newRecord({vehicle = "v2", field = 22, width = 30, sig = SIG, settings = settings, waypoints = 812})
local both = C.withRecord(records, other)
check("I2 original urørt", count(records), 1)
check("I3 begge", count(both), 2)
check("I4 without", count(C.without(both, "v1")), 1)
check("I5 without urørt original", count(both), 2)
check("I6 newRecord uden vehicle = nil", C.newRecord({field = 22}), nil)
-- ---- findReusable ----
local rec, how = C.findReusable(both, request, "v1")
check("F1 egen maskine først", rec and rec.vehicle, "v1")
check("F2 egen = own", how, "own")
rec, how = C.findReusable(both, request, "v3")
check("F3 anden maskines kursus", rec and rec.vehicle, "v1")
check("F4 = copy", how, "copy")
-- egen maskines kursus er til en anden bredde -> kopi fra v2
local mixed = C.withRecord(both, C.newRecord({vehicle = "v1", field = 22, width = 12, sig = SIG, settings = settings}))
rec, how = C.findReusable(mixed, request, "v1")
check("F5 egen passer ikke -> kopi fra v2", rec and rec.vehicle, "v2")
check("F6 kopi", how, "copy")
-- intet passer -> grunden fra egen post
local changed = {field = 22, width = 30, sig = "n=6;a=1;c=0,0", settings = settings}
rec, how = C.findReusable(both, changed, "v1")
check("F7 intet passer", rec, nil)
check("F8 grund: form ændret", how, "markens form ændret")
rec, how = C.findReusable({}, request, "v1")
check("F9 tomt register", how, "intet kursus gemt")
-- gyldighedstjek: egen maskines kursus er væk/ændret -> kopi fra v2; alle ugyldige -> ny
rec, how = C.findReusable(both, request, "v1", function(r) return r.vehicle ~= "v1" end)
check("F10 egen ugyldig -> kopi", rec and rec.vehicle, "v2")
rec, how = C.findReusable(both, request, "v1", function() return false end)
check("F11 alle ugyldige", rec, nil)
check("F12 grund: kurset på maskinen er ændret", how, "kurset på maskinen er ændret")
-- multi-tool: lederens kursus (2 sæt, bane -1) genbruges ikke til et enkelt sæt
local multi = C.newRecord({vehicle = "v1", field = 22, width = 30, sig = SIG, settings = C.settingsKey({multiTools = 2, lane = -1})})
rec = C.findReusable(C.withRecord({}, multi), request, "v1")
check("F13 multi-kursus != enkelt", rec, nil)
rec, how = C.findReusable(C.withRecord({}, multi), {field = 22, width = 30, sig = SIG, settings = C.settingsKey({multiTools = 2, lane = -1})}, "v1")
check("F14 multi samme bane genbruges", how, "own")
-- deterministisk: flere kandidater -> laveste id
local many = C.withRecord(C.withRecord({}, C.newRecord({vehicle = "v9", field = 22, width = 30, sig = SIG, settings = settings})),
C.newRecord({vehicle = "v4", field = 22, width = 30, sig = SIG, settings = settings}))
rec = C.findReusable(many, request, "v1")
check("F15 deterministisk valg", rec and rec.vehicle, "v4")
-- ---- invalidateField ----
local fieldRecords = C.withRecord(both, C.newRecord({vehicle = "v5", field = 7, width = 30, sig = "n=3;a=50;c=3,3", settings = settings}))
local kept, removed = C.invalidateField(fieldRecords, 22, "n=5;a=1;c=0,0")
check("V1 to poster for marken slettet", removed, 2)
check("V2 anden mark bevaret", kept.v5 ~= nil, true)
check("V3 original urørt", count(fieldRecords), 3)
kept, removed = C.invalidateField(fieldRecords, 22, SIG)
check("V4 samme form: intet slettes", removed, 0)
check("V4b samme tabel tilbage", kept, fieldRecords)
kept, removed = C.invalidateField(fieldRecords, 22, nil)
check("V5 ukendt form: intet slettes", removed, 0)
kept, removed = C.invalidateField(nil, 22, SIG)
check("V6 nil-register", removed, 0)
-- ---- lim: prepare/afterStart/checkField med falske spil-objekter ----
dofile("FS25_ADSmartPickup/adCpBridge.lua")
local saves, logs = 0, {}
ADFieldJobs = {state = {courseRecords = {}}, save = function() saves = saves + 1 end,
log = function(_, fmt, ...) table.insert(logs, string.format(fmt, ...)) end}
ADRuns = {vehicleLabel = function(v) return v.label end}
Logging = {warning = function(...) print("WARN", ...) end}
local function fakeCourse(n)
return {getNumberOfWaypoints = function() return n end}
end
local function fakeVehicle(id, width, course)
return {label = id, course = course,
getFieldWorkCourse = function(self) return self.course end,
getCourseGeneratorSettings = function() return {workWidth = {getValue = function() return width end}} end}
end
local tractorA = fakeVehicle("A", 30, nil)
local tractorB = fakeVehicle("B", 30, nil)
ADRunsController = {getFarmVehicles = function() return {tractorA, tractorB} end,
getVehicleId = function(v) return v.label end}
local fieldObj, info = {id = 22}, {shapeSig = SIG, fruitName = "WHEAT"}
local assignA = {vehicle = tractorA, vehicleId = "A", role = "cultivator"}
-- G1: intet gemt -> ny beregning, opts urørt (ingen reuse/course)
local opts, ctx = C.prepare({}, fieldObj, info, assignA, {})
check("G1 ny beregning: ingen reuse", opts.reuse, nil)
check("G1b ingen kursus", opts.course, nil)
check("G1c log: nyt kursus (intet gemt)", logs[#logs]:find("nyt kursus beregnes", 1, true) ~= nil and logs[#logs]:find("intet kursus gemt", 1, true) ~= nil, true)
-- CP har lavet kurset (812 punkter) -> registreres
tractorA.course = fakeCourse(812)
C.afterStart(tractorA, "A", ctx)
check("G2 registreret", ADFieldJobs.state.courseRecords.A ~= nil and ADFieldJobs.state.courseRecords.A.waypoints, 812)
check("G2b gemt til fil", saves, 1)
-- G3: næste gang -> genbrug
opts, ctx = C.prepare({}, fieldObj, info, assignA, {})
check("G3 genbrug", opts.reuse, true)
check("G3b log genbrugt", logs[#logs]:find("kursus genbrugt", 1, true) ~= nil, true)
C.afterStart(tractorA, "A", ctx)
check("G3c uændret register gemmes ikke igen", saves, 1)
-- G4: B skal samme mark -> kopi fra A
local assignB = {vehicle = tractorB, vehicleId = "B", role = "cultivator"}
opts, ctx = C.prepare({}, fieldObj, info, assignB, {})
check("G4 kopi fra A", opts.course, tractorA.course)
check("G4b log kopieret", logs[#logs]:find("kopieret fra 'A'", 1, true) ~= nil, true)
tractorB.course = fakeCourse(812)
C.afterStart(tractorB, "B", ctx)
check("G4c B registreret", ADFieldJobs.state.courseRecords.B ~= nil, true)
-- G5: spilleren har lavet et nyt kursus på A i CP's HUD (andet antal punkter) -> kopi fra B
tractorA.course = fakeCourse(400)
opts = C.prepare({}, fieldObj, info, assignA, {})
check("G5 A's kursus ændret -> kopi fra B", opts.course, tractorB.course)
-- G6: kursus kopieret fra slåmaskinen (opts.course) -> registret bruges ikke, maskinens post glemmes
local mowerCourse = fakeCourse(99)
opts, ctx = C.prepare({}, fieldObj, info, assignB, {course = mowerCourse})
check("G6 opts urørt", opts.course, mowerCourse)
check("G6b ingen kontekst", ctx, nil)
tractorB.course = fakeCourse(99)
C.afterStart(tractorB, "B", ctx)
check("G6c B glemt", ADFieldJobs.state.courseRecords.B, nil)
-- G7: vinmark: vin-offset indgår i nøglen
local vineReq = C.requestFor(tractorA, 22, {shapeSig = SIG, fruitName = "GRAPE"}, "fertilizer", {})
check("G7 vin i nøglen", vineReq.settings, C.settingsKey({vineOffset = -1, vineSkip = 1}))
-- G8: markens form ændret -> A's post glemmes, log
local before = saves
check("G8 checkField sletter", C.checkField(22, "n=9;a=1;c=0,0"), 1)
check("G8b register tomt for marken", ADFieldJobs.state.courseRecords.A, nil)
check("G8c gemt", saves, before + 1)
check("G9 samme form: intet", C.checkField(22, "n=9;a=1;c=0,0"), 0)
-- G10: fejl i spil-API'et -> falder tilbage til ny beregning (opts urørt), ingen crash
local brokenVehicle = {label = "X", getFieldWorkCourse = function() error("boom") end}
local plainOpts = {lane = 1}
local okPrep, gotOpts, gotCtx = pcall(C.prepare, {}, fieldObj, nil, {vehicle = brokenVehicle, vehicleId = "X", role = "x"}, plainOpts)
check("G10 ingen crash", okPrep, true)
check("G10b opts urørt", gotOpts, plainOpts)
check("G10c ingen kontekst", gotCtx, nil)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
+33
View File
@@ -115,5 +115,38 @@ check("PL tilladt", ADCpBridge.isPlantableIn(fruitPlant, 3), true)
check("PL ikke nu", ADCpBridge.isPlantableIn(fruitPlant, 4), false)
check("PL ukendt data = tilladt", ADCpBridge.isPlantableIn({}, 4), true)
-- CR: kursus-genbrug — start på maskinens eget kursus uden at CP beregner et nyt (opts.reuse)
do
local generated, started = 0, 0
AIJobType = {FIELDWORK_CP = 1}
function getWorldTranslation() return 0, 0, 0 end
local job = {cpJobParameters = {}}
function job.applyCurrentState() end
function job.setValues() end
function job.validate() return true end
function job.registerFieldBoundaryDetectionCallback() end
function job.detectFieldBoundary() return true, false, "" end
function job.onClickGenerateFieldWorkCourse(_, callback) generated = generated + 1; callback(true) end
g_currentMission = {aiJobTypeManager = {createJob = function() return job end},
aiSystem = {startJob = function() started = started + 1 end}}
local course = {}
local vehicle = {rootNode = 1, getOwnerFarmId = function() return 1 end, getFieldWorkCourse = function() return course end}
local reason = "?"
ADCpBridge.start(vehicle, 0, 0, function(ok, r) reason = ok and (r or "generated") or "fail" end, {reuse = true})
check("CR1 genbrug: ingen beregning", generated, 0)
check("CR2 genbrug: CP startet", started, 1)
check("CR3 genbrug: reason", reason, "reused")
-- ingen kursus på maskinen -> beregnes alligevel
vehicle.getFieldWorkCourse = function() return nil end
ADCpBridge.start(vehicle, 0, 0, function(ok, r) reason = ok and (r or "generated") or "fail" end, {reuse = true})
check("CR4 intet kursus -> beregnes", generated, 1)
check("CR5 reason ny", reason, "generated")
-- uden reuse: beregnes som før
vehicle.getFieldWorkCourse = function() return course end
ADCpBridge.start(vehicle, 0, 0, function(ok, r) reason = ok and (r or "generated") or "fail" end, {})
check("CR6 uden reuse -> beregnes", generated, 2)
AIJobType, g_currentMission = nil, nil
end
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
+54
View File
@@ -38,5 +38,59 @@ for key, value in pairs(record) do
check("ST2 rundtur: " .. key, tostring(again[key]), tostring(value))
end
-- ST3-ST6: kursus-registret (genbrug af Courseplay-kurser) gemmes i samme fil, <courses><course .../></courses>
dofile("FS25_ADSmartPickup/adCourseCache.lua")
local course = ADCourseCache.newRecord({vehicle = "v7", field = 22, width = 30.0, sig = "n=4;a=960000;c=800,300",
settings = ADCourseCache.settingsKey({multiTools = 2, lane = -1}), waypoints = 812})
for key in pairs(course) do
check("ST3 kursus-felt med i lagringen: " .. key, ADFieldStorage.COURSE_TYPES[key] ~= nil, true)
end
local cxml = fakeXml()
ADFieldStorage.writeCourseRecord(cxml, "c", course)
local cback = ADFieldStorage.readCourseRecord(cxml, "c")
for key, value in pairs(course) do
check("ST4 kursus-rundtur: " .. key, tostring(cback[key]), tostring(value))
end
check("ST4b matcher stadig efter rundtur", ADCourseCache.matches(cback, {field = 22, width = 30, sig = course.sig, settings = course.settings}), true)
-- hel fil: save -> load med falsk XMLFile (iterate over indekserede nøgler)
local function fileXml(store)
local xml = fakeXml()
for _, kind in ipairs({"Int", "String", "Float"}) do
xml["set" .. kind] = function(_, key, value) store[key] = value end
xml["get" .. kind] = function(_, key) return store[key] end
end
function xml.iterate(_, base, fn)
local index = 0
while true do
local prefix = string.format("%s(%d)", base, index)
local found = false
for key in pairs(store) do
if key:sub(1, #prefix + 1) == prefix .. "#" or key:sub(1, #prefix + 1) == prefix .. "." then found = true break end
end
if not found then return end
fn(index + 1, prefix)
index = index + 1
end
end
function xml.save() end
function xml.delete() end
return xml
end
local disk = {}
ADRunsStorage = {FILE_NAME = "runs.xml", getPath = function() return "/save/runs.xml" end}
XMLFile = {create = function() return fileXml(disk) end, load = function() return fileXml(disk) end}
function fileExists() return true end
check("ST5 gem", ADFieldStorage.save({fields = {[77] = field}, fleet = {}, fleetMode = "all",
courseRecords = ADCourseCache.withRecord({}, course)}), true)
local loaded = ADFieldStorage.load()
check("ST6 kursus indlæst", loaded.courseRecords.v7 ~= nil and loaded.courseRecords.v7.waypoints, 812)
check("ST6b mark indlæst", loaded.fields[77] ~= nil, true)
-- ST7: gammel fil uden <courses> -> tomt register (bagudkompatibelt)
disk = {}
ADFieldStorage.save({fields = {}, fleet = {}, fleetMode = "all"})
local old = ADFieldStorage.load()
check("ST7 gammel fil: tomt register", next(old.courseRecords), nil)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
+1
View File
@@ -56,6 +56,7 @@ check("PG3 majsen findes trods skiltet på bar stribe", info and info.fruitName,
check("PG4 ikke bar mark", info and info.kind, "harvest")
check("PG5 markør ved kanten findes (afstand til omridset)", info and info.markerId, 900)
check("PG6 afstand = til kanten", info and info.markerDistance, 5)
check("PG6b markens form-signatur (kursus-genbrug)", info and info.shapeSig, "n=4;a=960000;c=800,300")
-- PG7-9: field.fieldState er spillets sandhed (fields.xml: MAIZE growthState 2); jord-bits fra
-- getFieldDataAtWorldPosition (2. værdi) er IKKE vækststadie. Høstet mark (cutState) = bar, vist som stub.
+44
View File
@@ -0,0 +1,44 @@
-- Kør: luajit tests/test_adFieldsShape.lua (fra repo-roden)
-- Markens form-fingeraftryk (genbrug af Courseplay-kurser): antal hjørner, areal og tyngdepunkt, afrundet så
-- små float-forskelle ikke tæller — men en ændret mark (nyt hjørne, flyttet kant) giver en ny signatur.
dofile("FS25_ADSmartPickup/adFields.lua")
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 function rect(x0, z0, w, h)
return {{x = x0, z = z0}, {x = x0 + w, z = z0}, {x = x0 + w, z = z0 + h}, {x = x0, z = z0 + h}}
end
-- S1: rektangel 1600 x 600 -> 4 hjørner, 960.000 m², tyngdepunkt (800, 300)
check("S1 rektangel", ADFields.shapeSignature(rect(0, 0, 1600, 600)), "n=4;a=960000;c=800,300")
-- S2: omløbsretning er ligegyldig (arealet er absolut)
local reversed = {}
for index = 4, 1, -1 do table.insert(reversed, rect(0, 0, 1600, 600)[index]) end
check("S2 modsat omløb", ADFields.shapeSignature(reversed), "n=4;a=960000;c=800,300")
-- S3: små float-forskelle (cm) tæller ikke
local noisy = rect(0.0003, -0.0002, 1600.0004, 599.9997)
check("S3 float-støj", ADFields.shapeSignature(noisy), "n=4;a=960000;c=800,300")
-- S4: flyttet kant (1 m bredere) -> ny signatur
check("S4 kant flyttet", ADFields.shapeSignature(rect(0, 0, 1601, 600)) ~= ADFields.shapeSignature(rect(0, 0, 1600, 600)), true)
-- S5: ekstra hjørne på kanten (samme areal) -> ny signatur
local extra = {{x = 0, z = 0}, {x = 800, z = 0}, {x = 1600, z = 0}, {x = 1600, z = 600}, {x = 0, z = 600}}
check("S5 ekstra hjørne", ADFields.shapeSignature(extra), "n=5;a=960000;c=800,300")
-- S6: samme form et andet sted på kortet -> anden signatur
check("S6 flyttet mark", ADFields.shapeSignature(rect(100, 0, 1600, 600)), "n=4;a=960000;c=900,300")
-- S7: tom/ingen polygon -> nil
check("S7 tom", ADFields.shapeSignature({}), nil)
check("S8 nil", ADFields.shapeSignature(nil), nil)
-- S9: under 3 hjørner er ingen flade -> nil
check("S9 to punkter", ADFields.shapeSignature({{x = 0, z = 0}, {x = 1, z = 1}}), nil)
-- S10: trekant: areal 50, tyngdepunkt (10/3, 10/3) -> (3, 3)
check("S10 trekant", ADFields.shapeSignature({{x = 0, z = 0}, {x = 10, z = 0}, {x = 0, z = 10}}), "n=3;a=50;c=3,3")
-- S11: negative koordinater afrundes symmetrisk
check("S11 negative", ADFields.shapeSignature(rect(-1000, -500, 200, 100)), "n=4;a=20000;c=-900,-450")
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)