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
+238
View File
@@ -0,0 +1,238 @@
-- AD Course Cache
-- Genbrug af Courseplay-kurser. At lade CP beregne et kursus tager sekunder på store marker (Dakota 16x, 70+ ha)
-- og giver hak — men CP gemmer selv hver maskines seneste kursus i savegamet. Registret husker hvad kurset blev
-- lavet TIL: mark, bredde, markens form (ADFields.shapeSignature) og indstillingerne der former kurset
-- (værktøjsforskydning, multi-tool-sæt/bane, vin-offset/-skip). Passer alt, startes CP på maskinens eget kursus;
-- ellers kopieres en anden maskines matchende kursus; ellers beregnes et nyt, som så registreres.
-- Rene regler øverst (testet i tests/test_adCourseCache.lua); spil-nær lim nederst (alt pcall'et).
ADCourseCache = {}
-- ---- rene regler ----
local function round1(value)
local rounded = math.floor((tonumber(value) or 0) * 10 + 0.5) / 10
if rounded == 0 then rounded = 0 end -- ingen "-0.0"
return string.format("%.1f", rounded)
end
local function optional(value, format)
if value == nil then return "-" end
return format(value)
end
-- Indstillingerne der former kurset, som én streng (afrundet, så float-støj ikke tæller).
function ADCourseCache.settingsKey(s)
s = s or {}
return string.format("off=%s;lane=%s;multi=%d;vine=%s;skip=%s",
round1(s.toolOffset), optional(s.lane, tostring), tonumber(s.multiTools) or 1,
optional(s.vineOffset, round1), optional(s.vineSkip, tostring))
end
-- Ny post (kopi af felterne; nil uden køretøj).
function ADCourseCache.newRecord(values)
if values == nil or values.vehicle == nil then return nil end
return {vehicle = tostring(values.vehicle), field = tonumber(values.field), width = tonumber(values.width),
sig = values.sig, settings = values.settings, waypoints = tonumber(values.waypoints)}
end
-- Hvad kurset skal passe til: mark|bredde|form|indstillinger. nil hvis noget er ukendt (så genbruges intet).
function ADCourseCache.key(entry)
if entry == nil or entry.field == nil or entry.width == nil or entry.sig == nil or entry.settings == nil then return nil end
return string.format("%s|%s|%s|%s", tostring(entry.field), round1(entry.width), entry.sig, entry.settings)
end
function ADCourseCache.matches(record, request)
local key = ADCourseCache.key(record)
return key ~= nil and key == ADCourseCache.key(request)
end
-- Hvorfor maskinens gemte kursus ikke kan bruges (til loggen).
function ADCourseCache.mismatchReason(record, request)
if request == nil or request.sig == nil then return "markens form ukendt" end
if record == nil then return "intet kursus gemt" end
if record.field ~= request.field then return "kursus til en anden mark" end
if record.sig ~= request.sig then return "markens form ændret" end
if record.width == nil or round1(record.width) ~= round1(request.width) then return "ny bredde" end
if record.settings ~= request.settings then return "andre indstillinger" end
return nil
end
local function copyMap(records)
local result = {}
for id, record in pairs(records or {}) do result[id] = record end
return result
end
function ADCourseCache.withRecord(records, record)
local result = copyMap(records)
if record ~= nil then result[record.vehicle] = record end
return result
end
function ADCourseCache.without(records, vehicleId)
local result = copyMap(records)
if vehicleId ~= nil then result[tostring(vehicleId)] = nil end
return result
end
-- Kursus der kan genbruges: egen maskines først ("own"), ellers en anden maskines ("copy", laveste id først).
-- isValid(record) (valgfri) tjekker at kurset stadig sidder på maskinen. Intet: nil + grunden.
function ADCourseCache.findReusable(records, request, vehicleId, isValid)
records = records or {}
local ownId = vehicleId ~= nil and tostring(vehicleId) or nil
local own = ownId ~= nil and records[ownId] or nil
local anyStale = false
local function usable(record)
if not ADCourseCache.matches(record, request) then return false end
if isValid ~= nil and not isValid(record) then
anyStale = true
return false
end
return true
end
if own ~= nil and usable(own) then return own, "own" end
local ids = {}
for id in pairs(records) do
if id ~= ownId then table.insert(ids, id) end
end
table.sort(ids)
for _, id in ipairs(ids) do
if usable(records[id]) then return records[id], "copy" end
end
if anyStale then return nil, "kurset på maskinen er ændret" end
return nil, ADCourseCache.mismatchReason(own, request)
end
-- Marken har fået en anden form: dens poster (med anden signatur) slettes. Returnerer nyt register + antal.
-- Ukendt ny form (nil) sletter intet; intet slettet = samme tabel tilbage.
function ADCourseCache.invalidateField(records, fieldId, newSig)
if records == nil or newSig == nil then return records or {}, 0 end
local result, removed = {}, 0
for id, record in pairs(records) do
if record.field == fieldId and record.sig ~= newSig then
removed = removed + 1
else
result[id] = record
end
end
if removed == 0 then return records, 0 end
return result, removed
end
-- ---- spil-nær lim ----
local function log(...) ADFieldJobs.log(...) end
local function getRecords() return ADFieldJobs.state.courseRecords or {} end
local function setRecords(records)
ADFieldJobs.state.courseRecords = records
ADFieldJobs.save()
end
local function waypointCount(course)
if course == nil or course.getNumberOfWaypoints == nil then return nil end
local ok, n = pcall(course.getNumberOfWaypoints, course)
return ok and tonumber(n) or nil
end
-- Kurset sidder stadig på maskinen og har samme antal punkter som da det blev registreret (spilleren kan
-- have lavet et nyt kursus i CP's HUD).
local function courseStillThere(vehicle, record)
local course = ADCpBridge.getCourse(vehicle)
if course == nil then return nil end
local n = waypointCount(course)
if record.waypoints ~= nil and n ~= record.waypoints then return nil end
return course
end
local function vehicleById(adEnv, id)
local ok, found = pcall(function()
for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
if ADRunsController.getVehicleId(vehicle) == id then return vehicle end
end
return nil
end)
return ok and found or nil
end
-- Hvad kurset til denne start skal passe til.
function ADCourseCache.requestFor(vehicle, fieldId, info, role, opts)
local vineOffset = ADCpBridge.vineOffsetFor(info.fruitName, role)
return {
field = fieldId, width = ADCpBridge.getWorkWidth(vehicle), sig = info.shapeSig,
settings = ADCourseCache.settingsKey({
toolOffset = ADCpBridge.getToolOffset(vehicle), lane = opts.lane, multiTools = opts.multiTools or 1,
vineOffset = vineOffset, vineSkip = vineOffset ~= nil and ADCpBridge.vineSkipFor(info.fruitName, role) or nil,
}),
}
end
-- Før ADCpBridge.start: genbrug/kopi/ny beregning. Returnerer (nye) opts + kontekst til afterStart.
-- Har opts allerede et kursus (slåmaskinens/rivens, lederens multi-tool-kursus), bruges registret ikke.
local function prepareUnsafe(adEnv, field, info, assignment, opts)
if opts.course ~= nil then return opts, nil end
local vehicle = assignment.vehicle
local ok, request = pcall(ADCourseCache.requestFor, vehicle, field.id, info, assignment.role, opts)
if not ok then return opts, nil end
local vehicles = {}
local function isValid(record)
local owner = record.vehicle == assignment.vehicleId and vehicle or vehicleById(adEnv, record.vehicle)
local course = owner ~= nil and courseStillThere(owner, record) or nil
vehicles[record.vehicle] = course ~= nil and {vehicle = owner, course = course} or nil
return course ~= nil
end
local record, how = ADCourseCache.findReusable(getRecords(), request, assignment.vehicleId, isValid)
local result = {}
for k, v in pairs(opts) do result[k] = v end
if how == "own" then
result.reuse = true
log(field.id, "kursus genbrugt ('%s', %.1f m) — ingen ny beregning", ADRuns.vehicleLabel(vehicle), request.width or 0)
elseif how == "copy" then
local source = vehicles[record.vehicle]
result.course = source.course
log(field.id, "kursus kopieret fra '%s' til '%s' — ingen ny beregning", ADRuns.vehicleLabel(source.vehicle), ADRuns.vehicleLabel(vehicle))
else
log(field.id, "nyt kursus beregnes til '%s' (%s)", ADRuns.vehicleLabel(vehicle), tostring(how))
end
return result, {request = request, how = how}
end
function ADCourseCache.prepare(adEnv, field, info, assignment, opts)
local ok, result, context = pcall(prepareUnsafe, adEnv, field, info, assignment, opts)
if ok then return result, context end
Logging.warning("ADSmartPickup: kursus-register (Courseplay laver nyt kursus): %s", tostring(result))
return opts, nil
end
-- Efter CP er startet: maskinens nuværende kursus registreres (nyt, genbrugt eller kopieret fra registret).
-- Uden kontekst (kursus kopieret fra slåmaskinen/lederen) passer det ikke til maskinens egen anmodning: glem.
function ADCourseCache.afterStart(vehicle, vehicleId, context)
if vehicleId == nil then return end
local ok, err = pcall(function()
local records = getRecords()
local course = context ~= nil and ADCpBridge.getCourse(vehicle) or nil
if course == nil or ADCourseCache.key(context.request) == nil then
if records[tostring(vehicleId)] ~= nil then setRecords(ADCourseCache.without(records, vehicleId)) end
return
end
local r = context.request
local record = ADCourseCache.newRecord({vehicle = vehicleId, field = r.field, width = r.width, sig = r.sig,
settings = r.settings, waypoints = waypointCount(course)})
local existing = records[record.vehicle]
if existing ~= nil and ADCourseCache.matches(existing, r) and existing.waypoints == record.waypoints then return end
setRecords(ADCourseCache.withRecord(records, record))
end)
if not ok then Logging.warning("ADSmartPickup: kursus-register: %s", tostring(err)) end
end
-- Markens form er (måske) ændret: poster med anden signatur glemmes (scanning og markguidens Gem).
function ADCourseCache.checkField(fieldId, sig)
local records = getRecords()
local kept, removed = ADCourseCache.invalidateField(records, fieldId, sig)
if removed > 0 then
setRecords(kept)
log(fieldId, "markens form er ændret — %d gemt(e) kursus(er) glemt, næste start beregner nyt", removed)
end
return removed
end
+8 -2
View File
@@ -470,7 +470,8 @@ end
-- course = færdigt kursus der kopieres (slåmaskinens/rivens, eller trinnets fælles multi-tool-kursus)
-- multiTools = antal sæt når kurset laves her (multi-tool-leder)
-- lane = køretøjets bane på et multi-tool-kursus (laneOffset -2..2)
-- onDone(ok, reason) kaldes én gang; reason "copied" ved succes med kopieret kursus.
-- reuse = start på køretøjets eget nuværende kursus uden ny beregning (ADCourseCache fandt det passende)
-- onDone(ok, reason) kaldes én gang; reason "copied"/"reused" ved succes med kopieret/genbrugt kursus.
-- Vinmarker (druer/oliven): Courseplays kursus ligger som standard PÅ vinen — en sprøjte/spreder/harve kører
-- så ind i stolperne ("Blocked by an object", in-game 26/9 F22). Alt der arbejder i gangene køres til
-- venstre for vinen; høsteren kører hen over rækken. nil = ikke en vinmark (rør intet).
@@ -525,10 +526,15 @@ function ADCpBridge.start(vehicle, fieldX, fieldZ, onDone, opts)
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, handle.copiedCourse and "copied" or nil)
finish(true, handle.copiedCourse and "copied" or (handle.reusedCourse and "reused" or nil))
end
local function generateAndStart()
if handle.cancelled then return end
-- genbrug: maskinens eget kursus (CP's validate kræver kun hasCpCourse) — ingen ny beregning
if opts.reuse and ADCpBridge.getCourse(vehicle) ~= nil then
handle.reusedCourse = true
return startWithCourse()
end
if opts.course ~= nil and vehicle.cpCopyCourse ~= nil then
local copied, copyError = pcall(vehicle.cpCopyCourse, vehicle, opts.course)
if copied then
+3 -1
View File
@@ -15,7 +15,8 @@ ADFieldJobs.tickCount = 0
-- Courseplay kan efterlade hjørner på skæve marker: efter så mange færdige ture pr. sæt går marken videre.
ADFieldJobs.MAX_PASSES = 2
-- fleetMode: "all" = alle ledige maskiner (standard, "vælg og glem"), "ticked" = kun med flueben
ADFieldJobs.state = {fields = {}, fleet = {}, assignments = {}, fleetMode = "all"}
-- courseRecords: vehicleId -> post i kursus-registret (ADCourseCache)
ADFieldJobs.state = {fields = {}, fleet = {}, assignments = {}, fleetMode = "all", courseRecords = {}}
-- mark-id -> {mower = kursus, rake = kursus, <trin> = fælles multi-tool-kursus}: kopier (kun i hukommelsen;
-- efter genindlæsning hentes slå-/rivekurset fra maskinen, som CP selv gemmer kurset på)
ADFieldJobs.courses = {}
@@ -140,6 +141,7 @@ function ADFieldJobs.load()
if ok then
ADFieldJobs.state.fields, ADFieldJobs.state.fleet = loaded.fields, loaded.fleet
ADFieldJobs.state.fleetMode = loaded.fleetMode or "all"
ADFieldJobs.state.courseRecords = loaded.courseRecords or {}
else
Logging.warning(ADFieldJobs.LOG_PREFIX .. "marker kunne ikke indlæses: %s", tostring(loaded))
end
+30 -1
View File
@@ -16,6 +16,11 @@ ADFieldStorage.FIELD_TYPES = {
dedicated = "String",
}
-- Kursus-registret (ADCourseCache): hvilken mark/bredde/form/indstillinger hver maskines CP-kursus er lavet til.
ADFieldStorage.COURSE_TYPES = {
vehicle = "String", field = "Int", width = "Float", sig = "String", settings = "String", waypoints = "Int",
}
function ADFieldStorage.getPath()
local runsPath = ADRunsStorage ~= nil and ADRunsStorage.getPath() or nil
if runsPath == nil then return nil end
@@ -37,6 +42,21 @@ function ADFieldStorage.readRecord(xmlFile, key)
return record
end
function ADFieldStorage.writeCourseRecord(xmlFile, key, record)
for name, kind in pairs(ADFieldStorage.COURSE_TYPES) do
local value = record[name]
if value ~= nil then xmlFile["set" .. kind](xmlFile, key .. "#" .. name, value) end
end
end
function ADFieldStorage.readCourseRecord(xmlFile, key)
local record = {}
for name, kind in pairs(ADFieldStorage.COURSE_TYPES) do
record[name] = xmlFile["get" .. kind](xmlFile, key .. "#" .. name)
end
return record
end
function ADFieldStorage.save(state)
local path = ADFieldStorage.getPath()
if path == nil then return false end
@@ -53,13 +73,18 @@ function ADFieldStorage.save(state)
xmlFile:setString(string.format("%s.fleet.vehicle(%d)#id", ADFieldStorage.ROOT, vehicleIndex), vehicleId)
vehicleIndex = vehicleIndex + 1
end
local courseIndex = 0
for _, record in pairs(state.courseRecords or {}) do
ADFieldStorage.writeCourseRecord(xmlFile, string.format("%s.courses.course(%d)", ADFieldStorage.ROOT, courseIndex), record)
courseIndex = courseIndex + 1
end
xmlFile:save()
xmlFile:delete()
return true
end
function ADFieldStorage.load()
local result = {fields = {}, fleet = {}, fleetMode = "all"}
local result = {fields = {}, fleet = {}, fleetMode = "all", courseRecords = {}}
local path = ADFieldStorage.getPath()
if path == nil or not fileExists(path) then return result end
local xmlFile = XMLFile.load("ADSmartPickupFields", path)
@@ -73,6 +98,10 @@ function ADFieldStorage.load()
local id = xmlFile:getString(key .. "#id")
if id ~= nil and id ~= "" then result.fleet[id] = true end
end)
xmlFile:iterate(ADFieldStorage.ROOT .. ".courses.course", function(_, key)
local record = ADCourseCache ~= nil and ADCourseCache.newRecord(ADFieldStorage.readCourseRecord(xmlFile, key)) or nil
if record ~= nil then result.courseRecords[record.vehicle] = record end
end)
xmlFile:delete()
return result
end
+5 -1
View File
@@ -722,12 +722,16 @@ local function beginWork(adEnv, field, info, assignment)
end
end
local point = info.points[1] or {x = info.x, z = info.z}
-- genbrug af Courseplay-kurser: maskinens eget / en andens matchende kursus frem for ny beregning
local cacheContext
opts, cacheContext = ADCourseCache.prepare(adEnv, field, info, assignment, opts)
assignment.phase = "starting"
assignment.since = now()
local handle, err = ADCpBridge.start(vehicle, point.x, point.z, function(ok, reason)
if ok then
assignment.phase = "working"
assignment.since = now()
ADCourseCache.afterStart(vehicle, assignment.vehicleId, cacheContext)
if assignment.role == "mower" then
note(field.id, {mowWidth = assignment.width, mowVehicleId = assignment.vehicleId})
rememberCourse(field, "mower", vehicle)
@@ -742,7 +746,7 @@ local function beginWork(adEnv, field, info, assignment)
rememberCourse(field, field.stage, vehicle)
end
log(field.id, "Courseplay startet (%s%s%s)", assignment.role,
reason == "copied" and (", " .. tostring(source) .. "-kursus kopieret") or "",
reason == "copied" and source ~= nil and (", " .. tostring(source) .. "-kursus kopieret") or "",
assignment.lane ~= nil and string.format(", bane %d", assignment.lane) or "")
else
ADFieldWork.release(assignment)
+30
View File
@@ -194,6 +194,33 @@ function ADFields.readPolygon(field)
return verts
end
-- Markens form-fingeraftryk: antal hjørner, areal (shoelace, nærmeste 10 m²) og tyngdepunkt (nærmeste meter).
-- Samme mark giver samme streng trods små float-forskelle; en ændret mark (hjørne/kant) giver en ny.
-- Bruges til at genbruge Courseplay-kurser (ADCourseCache). nil uden en flade (under 3 hjørner).
ADFields.SHAPE_AREA_STEP = 10
local function roundTo(value, step)
return math.floor(value / step + 0.5) * step
end
function ADFields.shapeSignature(verts)
if type(verts) ~= "table" or #verts < 3 then return nil end
local n = #verts
local twiceArea, sx, sz = 0, 0, 0
for index = 1, n do
local a, b = verts[index], verts[index % n + 1]
local cross = a.x * b.z - b.x * a.z
twiceArea = twiceArea + cross
sx, sz = sx + (a.x + b.x) * cross, sz + (a.z + b.z) * cross
end
local cx, cz
if math.abs(twiceArea) > 1e-6 then
cx, cz = sx / (3 * twiceArea), sz / (3 * twiceArea)
else
cx, cz = ADFields.polygonCenter(verts)
end
return string.format("n=%d;a=%d;c=%d,%d", n, roundTo(math.abs(twiceArea) / 2, ADFields.SHAPE_AREA_STEP),
roundTo(cx, 1), roundTo(cz, 1))
end
-- Hjørnernes gennemsnit (nil uden hjørner).
function ADFields.polygonCenter(verts)
if verts == nil or #verts == 0 then return nil end
@@ -393,6 +420,9 @@ local function finishScan(adEnv, scan)
info.markerDistance = distance
info.markerCheckedAt = ADFields.now
end
-- markens form: ændret mark (anden signatur end kursus-registret) -> gemte kurser til marken glemmes
info.shapeSig = ADFields.shapeSignature(info.verts)
if ADCourseCache ~= nil and ADCourseCache.checkField ~= nil then pcall(ADCourseCache.checkField, info.id, info.shapeSig) end
ADFields.cache[info.id] = info
ADFields.listCache = nil
end
@@ -189,6 +189,9 @@ function SmartPickupFrame:saveFieldGuide(guide, start)
if result.fieldId == nil then return end
if result.targetChanged then ADFieldJobs.setTarget(result.fieldId, result.targetFruit) end
ADFieldJobs.setPlan(result.fieldId, result.plan)
-- markens form ændret siden kurserne blev lavet: gemte Courseplay-kurser til marken glemmes
local fieldInfo = ADFields.getInfo(result.fieldId)
if fieldInfo ~= nil and ADCourseCache ~= nil then ADCourseCache.checkField(result.fieldId, fieldInfo.shapeSig) end
local byField = SmartPickupFrame.rigIdsByField()
for fieldId, ids in pairs(ADFieldGuide.applyRigs(byField, result.fieldId, result.rigIds)) do
if table.concat(byField[fieldId] or {}, " ") ~= table.concat(ids, " ") then
+2 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<modDesc descVersion="107">
<author>masterdraco</author>
<version>1.33.4.0</version>
<version>1.34.0.0</version>
<title>
<en>AD Smart Pickup</en>
</title>
@@ -35,6 +35,7 @@
<sourceFile filename="adFieldGuide.lua"/>
<sourceFile filename="adFields.lua"/>
<sourceFile filename="adCpBridge.lua"/>
<sourceFile filename="adCourseCache.lua"/>
<sourceFile filename="adFieldStorage.lua"/>
<sourceFile filename="adFieldWork.lua"/>
<sourceFile filename="adFieldJobs.lua"/>
+29
View File
@@ -205,6 +205,34 @@ Afgrøde "Samme som høstet", Tromle Ja. Kalk Ja.
---
## 12. Genbrug af Courseplay-kurser (v1.34)
Log-linjer at kigge efter (`ADSmartPickup: F<nr> …`): `nyt kursus beregnes til '<maskine>' (<grund>)`,
`kursus genbrugt ('<maskine>', <bredde> m) — ingen ny beregning`, `kursus kopieret fra '<A>' til '<B>'`,
`markens form er ændret — N gemt(e) kursus(er) glemt`. Registret står i `ADSmartPickup_fields.xml` under `<courses>`.
- [ ] **12.1 Første gang = ny beregning.** Stor mark (Dakota 16x) → kultivator sendes ud → log `nyt kursus beregnes … (intet kursus gemt)`
→ CP starter; `<courses><course vehicle=… field=… width=… sig=… settings=… waypoints=…/>` står i filen efter gem.
- [ ] **12.2 Genbrug.** Samme maskine til samme mark igen (næste pleje/klargøring, eller efter "Stop" og start) → log
`kursus genbrugt` → CP kører UDEN beregnings-hak og kører hele marken fra start (ikke kun sidste stykke).
⚠ Usikkert: accepterer CP 8.1 at jobbet startes på det eksisterende kursus uden `onClickGenerateFieldWorkCourse`?
(validate kræver kun `hasCpCourse`). Tegn på fejl: `Courseplay: …no course…`, eller maskinen kører ingenting.
- [ ] **12.3 Efter genstart.** Gem, genindlæs, vent de 2 min → samme maskine til samme mark → `kursus genbrugt`
(CP har selv gemt kurset i CpAssignedCourses.xml; antal punkter matcher registret).
- [ ] **12.4 Kopi fra anden maskine.** En anden maskine med samme CP-bredde til samme mark → `kursus kopieret fra 'A'`
→ den kører kurset (ikke A's rest). ⚠ Usikkert: kopieret kursus + lane/vin som i dag med `cpCopyCourse`.
- [ ] **12.5 Ny bredde.** Skift CP-bredden i HUD'en (eller en anden maskine med anden bredde) → `nyt kursus beregnes … (ny bredde)`.
- [ ] **12.6 Spilleren laver selv kursus i CP's HUD** på maskinen → næste udsendelse genbruger det IKKE
(antal punkter passer ikke) → kopi fra en anden maskine eller `… (kurset på maskinen er ændret)`.
- [ ] **12.7 Ændret mark.** Lav marken om (kort-redigering/mark-tilkøb der ændrer omridset) → efter scanning, eller når
marken gemmes i "Opsæt mark", log `markens form er ændret — N … glemt` → næste start beregner nyt og gemmer det.
- [ ] **12.8 Multi-tool.** 2 sæt på samme mark: lederen genbruger kun et kursus lavet til 2 sæt/samme bane; følgeren kopierer
stadig lederens kursus (log `…, multi-kursus kopieret, bane N`), og begge kører hver sin bane.
- [ ] **12.9 Vinmark.** Sprøjte (skip 1) og harve (skip 0) på samme vinmark må ikke genbruge hinandens kursus (vin-indstillinger
indgår i nøglen); høsteren (offset 0) heller ikke.
---
## Kendte usikkerheder (første gang i spillet)
| Område | Hvad der er gættet | Tegn på fejl |
@@ -219,6 +247,7 @@ Afgrøde "Samme som høstet", Tromle Ja. Kalk Ja.
| Nysået som "bar" | nysået afgrøde læses som voksende | Marken står "Færdig" efter såning (ingen dobbelt-såning, men ingen næste høst-start før den er moden) |
| Halm-valg i guiden | `fruitType.hasWindrow/windrowName` = afgrøder med halm | Halm-valget mangler ved hvede eller vises ved majs |
| Ballesamler | Courseplays BALE_FINDER + AutoDrive til ballelager | Ballesamleren starter ikke, eller kører ikke fulde læs væk |
| Kursus-genbrug (v1.34) | CP-job startes på maskinens eksisterende kursus uden ny beregning | `Courseplay: no course`, eller maskinen starter midt/sidst i kurset |
- [ ] **Høsterens rest (v1.32.2).** Modet høster en mark med aflæsningsvogne → ved høstens slutning stoppes
vognene; høsteren med korn i tanken → log `'Mejetærsker' kører rest-læs (N l) til lager` → AutoDrive
+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)