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