From 7b0a9e452bf9451a21ce529a33c412d7b76286e4 Mon Sep 17 00:00:00 2001 From: masterdraco Date: Fri, 25 Sep 2026 23:57:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(marker):=20job-l=C3=B8kke=20delt=20i=20adF?= =?UTF-8?q?ieldJobs=20(beslutning)=20+=20adFieldWork=20(udsendelse)=20?= =?UTF-8?q?=E2=80=94=20h=C3=B8st=20m.=20afl=C3=A6sningsvogne,=20klarg?= =?UTF-8?q?=C3=B8ring,=20pleje,=20multi-tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FS25_ADSmartPickup/adFieldJobs.lua | 519 ++++------------ FS25_ADSmartPickup/adFieldWork.lua | 561 ++++++++++++++++++ .../gui/SmartPickupFieldTab.lua | 2 +- FS25_ADSmartPickup/modDesc.xml | 1 + tests/test_adFieldJobs.lua | 32 + 5 files changed, 695 insertions(+), 420 deletions(-) create mode 100644 FS25_ADSmartPickup/adFieldWork.lua diff --git a/FS25_ADSmartPickup/adFieldJobs.lua b/FS25_ADSmartPickup/adFieldJobs.lua index 5136390..c03233c 100644 --- a/FS25_ADSmartPickup/adFieldJobs.lua +++ b/FS25_ADSmartPickup/adFieldJobs.lua @@ -1,61 +1,86 @@ -- 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. +-- Markarbejdets job-løkke (hvert sekund): opdater markerne, start kæder og pleje, beslut hvilke sæt der +-- skal ud, og gå videre til næste trin. decide() er ren og testet; udsendelse og opfølgning af sættene +-- (AutoDrive/Courseplay) ligger i adFieldWork. Alt spil-nært 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.START_TIMEOUT_MS = 60000 -ADFieldJobs.PLAYER_DRIVING_KMH = 3 --- riven skal være udfoldet og i gang, før nedlægningsområdet måles -ADFieldJobs.WINDROW_MEASURE_MS = 10000 --- Courseplay kan efterlade hjørner på skæve marker: efter så mange færdige slå-ture går marken videre. -ADFieldJobs.MAX_MOW_PASSES = 2 +-- 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 ADFieldJobs.state = {fields = {}, fleet = {}, assignments = {}} --- mark-id -> {mower = kursus, rake = kursus}: kopier af slåmaskinens/rivens Courseplay-kursus (kun i --- hukommelsen; efter genindlæsning hentes de fra maskinen, som CP selv gemmer kurset på) +-- mark-id -> {mower = kursus, rake = kursus, = 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 = {} +ADFieldJobs.finished = {} ADFieldJobs.timer = 0 ADFieldJobs.now = 0 ADFieldJobs.loaded = false -local ACTIVE_PHASES = {driving = true, starting = true, working = true} +ADFieldJobs.ACTIVE_PHASES = {driving = true, starting = true, waitingCourse = true, working = true} -- ---- ren beslutning ---- +-- Er afgrøden taget (trin med afgrøde)? Slåning: under 50 % står. Høst: marken er bar eller under 50 %. +local function cropGone(stage, info) + if info == nil then return false end + if stage == "harvesting" and info.kind == "bare" then return true end + return (info.readyRatio or 0) < ADFieldModel.CUT_RATIO +end + +-- Beslutning for én mark. assignments = sæt på marken ({phase, stage, helper}); kun sæt på det aktuelle +-- trin tæller (en aflæsningsvogn på vej hjem fra høsten forstyrrer ikke kultiveringen). +-- Svar: {action = "start"|"care"|"advance"|"dispatch"|"wait"|"none", role, count, helper, stage, care}. function ADFieldJobs.decide(field, info, assignments, now) + local hasMarker = info ~= nil and info.markerId ~= nil 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 + if not hasMarker then return {action = "wait"} end return {action = "start"} end - if not ADFieldModel.isWorking(field) then return {action = "none"} end - local active, finished = 0, 0 + if not ADFieldModel.isWorking(field) then + local care = ADFieldModel.careNeed(field, info) + if care == nil then return {action = "none"} end + if field.plan.mode ~= "auto" and not field.startRequested then return {action = "none", care = care} end + if not hasMarker then return {action = "wait", care = care} end + return {action = "care", stage = care} + end + local active, finished, helpers = 0, 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 + if assignment.stage == nil or assignment.stage == field.stage then + local isActive = ADFieldJobs.ACTIVE_PHASES[assignment.phase] + if assignment.helper then + if isActive then helpers = helpers + 1 end + else + if isActive then active = active + 1 end + if assignment.phase == "finished" then finished = finished + 1 end + end + end end - -- slået mark uden sæt på (sluppet undervejs, genindlæst): slåningen er gjort, videre til næste trin - if active == 0 and field.stage == "mowing" and info ~= nil and info.mowable and (info.readyRatio or 1) < ADFieldModel.CUT_RATIO then - return {action = "advance"} + local isCropStage = ADFieldModel.CROP_STAGES[field.stage] == true + if active == 0 then + -- afgrøden er væk (sluppet undervejs, genindlæst, eller færdig): videre + if isCropStage and cropGone(field.stage, info) then return {action = "advance"} end + if finished > 0 then + local maxPasses = ADFieldJobs.MAX_PASSES * ADFieldModel.maxRigs(field.stage, field.plan) + if not isCropStage or finished >= maxPasses then return {action = "advance"} end + end + if not hasMarker then return {action = "wait"} end + if (field.retryAt or 0) > now then return {action = "none"} end + return {action = "dispatch", role = ADFieldModel.roleFor(field.stage), count = ADFieldModel.maxRigs(field.stage, field.plan), helper = false} 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 or finished >= ADFieldJobs.MAX_MOW_PASSES then return {action = "advance"} end + -- hovedsættene arbejder: fyld op med hjælpere (aflæsningsvogne) en ad gangen + local helperRole = ADFieldModel.helperFor(field.stage) + if helperRole ~= nil and helpers < ADFieldModel.maxHelpers(field.stage, field.plan) and hasMarker and (field.retryAt or 0) <= now then + return {action = "dispatch", role = helperRole, count = 1, helper = true} 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, ...) +function ADFieldJobs.log(fieldId, fmt, ...) Logging.info(ADFieldJobs.LOG_PREFIX .. "F%s " .. fmt, tostring(fieldId), ...) end @@ -63,10 +88,15 @@ function ADFieldJobs.getField(id) return ADFieldJobs.state.fields[id] or ADFieldModel.newField(id) end -local function setField(field) +function ADFieldJobs.setField(field) ADFieldJobs.state.fields[field.id] = field end +-- Ændr felter på marken (kopi via modellen). +function ADFieldJobs.note(fieldId, changes) + ADFieldJobs.setField(ADFieldModel.with(ADFieldJobs.getField(fieldId), changes)) +end + function ADFieldJobs.assignmentsFor(fieldId) local list = {} for _, assignment in pairs(ADFieldJobs.state.assignments) do @@ -91,20 +121,16 @@ function ADFieldJobs.load() end function ADFieldJobs.setPlan(id, changes) - setField(ADFieldModel.with(ADFieldJobs.getField(id), {plan = changes})) + ADFieldJobs.note(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)) + ADFieldJobs.note(id, {startRequested = true}) end function ADFieldJobs.forceReady(id) - setField(ADFieldModel.forceReady(ADFieldJobs.getField(id))) + ADFieldJobs.setField(ADFieldModel.forceReady(ADFieldJobs.getField(id))) end function ADFieldJobs.toggleFleet(vehicleId) @@ -112,406 +138,63 @@ function ADFieldJobs.toggleFleet(vehicleId) ADFieldJobs.save() end --- ---- AutoDrive ---- +-- GUI'et henter flåde og gæringssiloer herfra (ligger i adFieldWork). +function ADFieldJobs.getRigs(adEnv) return ADFieldWork.getRigs(adEnv) end +function ADFieldJobs.getFermenters(...) return ADFieldWork.getFermenters(...) end -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 - --- Parkerede køretøjer SOVER i FS25 (motoren kalder ikke onUpdate), så AutoDrives køreopgave står stille, --- til nogen vækker dem (in-game 25/9: Fastrac'en kørte først, da spilleren satte sig ind). Samme greb --- som flows (applyAndStart) + raiseActive, bevist i AI-Bonden. -local function wake(vehicle, kick) - if vehicle.raiseActive ~= nil then vehicle:raiseActive() end - if kick and vehicle.isServer and SpecializationUtil ~= nil and SpecializationUtil.raiseEvent ~= nil then - SpecializationUtil.raiseEvent(vehicle, "onUpdate", 16, false, false, false) - 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 - wake(vehicle, true) -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, - -- optaget: vores egen opgave, et Smart Pickup-flow, eller AutoDrive/Courseplay kører den allerede - -- (fx spillerens egen parkeringstur) — den må ikke kapres midt i en anden kørsel - busy = ADFieldJobs.state.assignments[id] ~= nil or ADRunsController.getRunForVehicle(vehicle) ~= nil - or isAdActive(vehicle) or ADCpBridge.isWorking(vehicle), - controlled = isControlledByPlayer(vehicle), - width = ADCpBridge.getWorkWidth(vehicle), x = x, z = z, - }) - end - end - return rigs -end - --- ---- silo ---- - --- Gårdens gæringssiloer for markens skår, nærmest marken først (produktioner skår -> SILAGE). -function ADFieldJobs.getFermenters(adEnv, farmId, windrowFillType, x, z) - local silage = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeIndexByName("SILAGE") or nil - local result = {} - for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do - local spec = building.kind == "factory" and building.placeable ~= nil and building.placeable.spec_productionPoint or nil - local point = spec ~= nil and spec.productionPoint or nil - if point ~= nil and ADFieldModel.isFermenter(point.productions, windrowFillType, silage) then - local bx, _, bz = getWorldTranslation(building.placeable.rootNode) - table.insert(result, {building = building, distance = (bx - (x or bx)) ^ 2 + (bz - (z or bz)) ^ 2}) - end - end - table.sort(result, function(a, b) return a.distance < b.distance end) - local buildings = {} - for _, entry in ipairs(result) do table.insert(buildings, entry.building) end - return buildings -end - -local function unloadMarkerIds(buildings) - local markerIds = {} - for _, building in ipairs(buildings) do - for _, marker in ipairs(ADBuildings.getMarkers(building, "unload") or {}) do - table.insert(markerIds, marker.id) - end - end - return markerIds -end - --- Tvunget lager -> de valgte; ensilage -> gæringssiloer (produktion skår -> ensilage) før almindelige --- siloer (in-game 25/9: græs endte i en Farma-silo); hø -> kun siloer, aldrig gæringssiloer (flere af dem --- laver også hø om til ensilage). -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 and field.plan.product ~= "hay" then - local fermenters = ADFieldJobs.getFermenters(adEnv, vehicle:getOwnerFarmId(), fillType, info.x, info.z) - if #fermenters > 0 then - local store, why = ADLoadSwap.findForcedDumpMarker(adEnv, vehicle, fillType, 1, unloadMarkerIds(fermenters), isReachable) - if store ~= nil then return store end - log(field.id, "ingen gæringssilo kan tage græsset (%s) — bruger almindelig silo", tostring(why)) - end - 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 ---- - --- Færdige sæt for en mark glemmes, når marken går videre eller stoppes. Listen er begrænset: en mark --- der sendes ud igen uden at gå videre (hjørner), går videre efter MAX_MOW_PASSES færdige ture. -local function forgetFinished(fieldId) +function ADFieldJobs.forgetFinished(fieldId) local remaining = {} - for _, done in ipairs(ADFieldJobs.finished or {}) do + for _, done in ipairs(ADFieldJobs.finished) do if done.fieldId ~= fieldId then table.insert(remaining, done) end end ADFieldJobs.finished = remaining end --- Læssevognens oprindelige CP-forskydning sættes tilbage, når mod'en slipper den. -local function restoreToolOffset(assignment) - if assignment.restoreToolOffset ~= nil then - ADCpBridge.setToolOffset(assignment.vehicle, assignment.restoreToolOffset) - assignment.restoreToolOffset = nil - end -end - -local function release(assignment, phase) - restoreToolOffset(assignment) - 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, ADFieldModel.requiredWidth(field, role)) - 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 - --- Kurset som et trin skal genbruge ("mower"/"rake"): gemt kopi, ellers maskinens nuværende kursus --- (efter genindlæsning). -local function sourceCourse(adEnv, field, source) - local saved = ADFieldJobs.courses[field.id] - if saved ~= nil and saved[source] ~= nil then return saved[source] end - local vehicleId = source == "rake" and field.rakeVehicleId or field.mowVehicleId - if vehicleId == nil then return nil end - for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do - if ADRunsController.getVehicleId(vehicle) == vehicleId then - return ADCpBridge.getCourse(vehicle) - end - end - return nil -end - -local function rememberCourse(field, source, vehicle, vehicleId) - local course = ADCpBridge.getCourse(vehicle) - local ok, copy = false, nil - if course ~= nil and course.copy ~= nil then ok, copy = pcall(course.copy, course) end - ADFieldJobs.courses[field.id] = ADFieldJobs.courses[field.id] or {} - ADFieldJobs.courses[field.id][source] = ok and copy or course - if source == "rake" then - note(field.id, {rakeVehicleId = vehicleId}) - end -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 source = ADFieldModel.courseSource(field, assignment.role) - local course = source ~= nil and sourceCourse(adEnv, field, source) or nil - if source ~= nil and course == nil then - log(field.id, "intet %s-kursus kendt — Courseplay laver et nyt kursus til %s", source, assignment.role) - end - if source == "rake" and field.windrowOffset ~= nil and math.abs(field.windrowOffset) > 0.05 then - local own = ADCpBridge.getToolOffset(vehicle) - local target = ADFieldModel.collectorOffset(own, field.windrowOffset) - if ADCpBridge.setToolOffset(vehicle, target) then - assignment.restoreToolOffset = own - log(field.id, "skår ligger %.1f m %s for rivens linje -> læssevognens forskydning %.1f m (var %.1f m)", - math.abs(field.windrowOffset), field.windrowOffset > 0 and "til venstre" or "til højre", target, own) - end - end - 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, mowVehicleId = assignment.vehicleId}) - rememberCourse(field, "mower", vehicle, assignment.vehicleId) - elseif assignment.role == "rake" then - rememberCourse(field, "rake", vehicle, assignment.vehicleId) - end - log(field.id, "Courseplay startet (%s%s)", assignment.role, reason == "copied" and (", " .. tostring(source) .. "-kursus kopieret") or "") - 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, course) - 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 - -- Kun når spilleren selv KØRER (hverken Courseplay eller AutoDrive styrer): at sætte sig ind for at - -- kigge på HUD'en mens CP slår, må ikke slippe sættet (in-game 25/9: F22 blev aldrig sendt hjem). - local steeredByAi = ADCpBridge.isWorking(vehicle) or isAdActive(vehicle) or assignment.phase == "starting" - -- ...og at sidde stille i førersædet når AutoDrive lige er nået frem, er heller ikke at køre selv - -- (in-game 25/9: læssevognen blev sluppet ved ankomsten til F22). Spilleren kører = den bevæger sig. - local speed = vehicle.getLastSpeed ~= nil and vehicle:getLastSpeed() or 0 - if isControlledByPlayer(vehicle) and not steeredByAi and speed > ADFieldJobs.PLAYER_DRIVING_KMH 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) - -- hold sættet vågent mens vi har det (AutoDrive-kørsel, CP-start, arbejde, AD-aflæsning) - wake(vehicle) - 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) - note(assignment.fieldId, {retryAt = ADFieldJobs.now + ADFieldModel.RETRY_MS, - reason = {text = string.format("%s nåede ikke %s", ADRuns.vehicleLabel(vehicle), tostring(info.markerName))}}) - end - end - elseif assignment.phase == "starting" then - if ADFieldJobs.now - assignment.since > ADFieldJobs.START_TIMEOUT_MS then - if assignment.handle ~= nil then assignment.handle.cancelled = true end - release(assignment) - note(assignment.fieldId, {retryAt = ADFieldJobs.now + ADFieldModel.RETRY_MS, reason = {text = "Courseplay svarede ikke (60 s)"}}) - log(assignment.fieldId, "Courseplay svarede ikke inden 60 s") - end - elseif assignment.phase == "working" then - if assignment.role == "rake" and field.windrowOffset == nil and ADFieldJobs.now - assignment.since >= ADFieldJobs.WINDROW_MEASURE_MS - and ADCpBridge.isWorking(vehicle) then - local offset = ADCpBridge.getWindrowOffset(vehicle) - note(assignment.fieldId, {windrowOffset = offset or 0}) - log(assignment.fieldId, offset ~= nil and "river lægger skåret %.1f m fra sin kursuslinje (venstre +)" or "river: nedlægningsområde ikke fundet — regner med midten (%.1f m)", offset or 0) - end - 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 - restoreToolOffset(assignment) - 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 - forgetFinished(id) - 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 + for _, assignment in ipairs(ADFieldJobs.finished) do if assignment.fieldId == fieldId then table.insert(list, assignment) end end return list end +function ADFieldJobs.stopField(adEnv, id) + ADFieldWork.stopAll(adEnv, id) + ADFieldJobs.forgetFinished(id) + ADFieldJobs.setField(ADFieldModel.stop(ADFieldJobs.getField(id))) + ADFieldJobs.save() + ADFieldJobs.log(id, "stoppet fra menuen") +end + +-- Seneste beslutning pr. mark (GUI viser pleje-behov og grunde). +ADFieldJobs.lastDecision = {} + local function stepField(adEnv, info) local field = ADFieldModel.onReadiness(ADFieldJobs.getField(info.id), info.readyRatio or 0) - setField(field) + ADFieldJobs.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) + ADFieldJobs.lastDecision[info.id] = decision if decision.action == "start" then - setField(ADFieldModel.start(field)) - log(info.id, "starter (%s)", field.plan.product) + local started = ADFieldModel.start(field, info.kind, info.needs) + started.lastFruit = info.kind == "harvest" and info.fruitName or field.lastFruit + ADFieldJobs.setField(started) + ADFieldJobs.log(info.id, "starter (%s: %s)", tostring(info.kind), started.stage) + ADFieldJobs.save() + elseif decision.action == "care" then + ADFieldJobs.setField(ADFieldModel.startCare(field, decision.stage)) + ADFieldJobs.log(info.id, "pleje: %s", decision.stage) ADFieldJobs.save() elseif decision.action == "advance" then - local nextField = ADFieldModel.advance(field) - setField(nextField) - forgetFinished(info.id) - log(info.id, "%s -> %s", field.stage, nextField.stage) + local nextField = ADFieldModel.advance(field, info.needs) + ADFieldJobs.setField(nextField) + ADFieldJobs.forgetFinished(info.id) + ADFieldWork.windDownHelpers(adEnv, info.id, field.stage) + ADFieldJobs.log(info.id, "%s -> %s", field.stage, nextField.stage) ADFieldJobs.save() elseif decision.action == "dispatch" then - dispatch(adEnv, field, info, decision.role) + ADFieldWork.dispatch(adEnv, field, info, decision) end end @@ -524,16 +207,14 @@ function ADFieldJobs.tick(adEnv, dt) local farmId = g_currentMission:getFarmId() ADFields.scanNext(adEnv, farmId) for _, assignment in pairs(ADFieldJobs.state.assignments) do - local ok, err = pcall(follow, adEnv, assignment) + local ok, err = pcall(ADFieldWork.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) + ADFieldWork.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 + 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 diff --git a/FS25_ADSmartPickup/adFieldWork.lua b/FS25_ADSmartPickup/adFieldWork.lua new file mode 100644 index 0000000..c816242 --- /dev/null +++ b/FS25_ADSmartPickup/adFieldWork.lua @@ -0,0 +1,561 @@ +-- AD Field Work +-- Udsendelse og opfølgning af markflådens sæt: AutoDrive kører sættet til markens F-markør, derfra starter +-- Courseplay arbejdet (eget kursus, kopieret kursus eller multi-tool-bane), og når det er færdigt kører +-- sættet hjem. Læssevogne tømmes af AutoDrive (Hent og aflever), aflæsningsvogne under høst er Courseplays +-- combine-unloader. Beslutningen om HVAD der sendes ud ligger i ADFieldJobs.decide. + +ADFieldWork = {} + +ADFieldWork.ARRIVE_DISTANCE = 30 +ADFieldWork.IDLE_CONFIRM_MS = 5000 +ADFieldWork.START_TIMEOUT_MS = 60000 +ADFieldWork.COURSE_WAIT_MS = 180000 +ADFieldWork.PLAYER_DRIVING_KMH = 3 +-- riven skal være udfoldet og i gang, før nedlægningsområdet måles +ADFieldWork.WINDROW_MEASURE_MS = 10000 + +local function log(...) ADFieldJobs.log(...) end +local function note(...) ADFieldJobs.note(...) end +local function now() return ADFieldJobs.now 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 + +-- Parkerede køretøjer SOVER i FS25 (motoren kalder ikke onUpdate), så AutoDrives køreopgave står stille, +-- til nogen vækker dem (in-game 25/9). Samme greb som flows (applyAndStart) + raiseActive fra AI-Bonden. +local function wake(vehicle, kick) + if vehicle.raiseActive ~= nil then vehicle:raiseActive() end + if kick and vehicle.isServer and SpecializationUtil ~= nil and SpecializationUtil.raiseEvent ~= nil then + SpecializationUtil.raiseEvent(vehicle, "onUpdate", 16, false, false, false) + end +end + +local function enableParkAtJobFinished(vehicle) + local setting = vehicle.ad.settings ~= nil and vehicle.ad.settings.enableParkAtJobFinished or nil + if setting ~= nil then + setting.current = 2 + setting.new = 2 + end +end + +local function helperCp(adEnv) + return adEnv.ADStateModule ~= nil and adEnv.ADStateModule.HELPER_CP or 1 +end + +local function startAd(adEnv, vehicle, mode, firstId, secondId) + local module = stateModule(vehicle) + stopAd(vehicle) + module:setMode(mode) + module:setFirstMarkerByWayPointId(firstId) + if secondId ~= nil then module:setSecondMarkerByWayPointId(secondId) end + module:setStartHelper(false) + if module.setActualFarmId ~= nil then module:setActualFarmId(vehicle:getOwnerFarmId()) 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 + wake(vehicle, true) +end + +-- AD-parkering: sidder på redskabet og kopieres af AD til traktorens stateModule (markør-INDEKS). +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 ADFieldWork.getRigs(adEnv) + local rigs = {} + local state = ADFieldJobs.state + for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do + local specs = ADCpBridge.getSpecs(vehicle) + local role = ADFieldFleet.roleFromSpecs(specs) + local id = ADRunsController.getVehicleId(vehicle) + if role ~= nil and id ~= nil then + local x, z = vehiclePosition(vehicle) + local rig = { + id = id, vehicle = vehicle, role = role, label = ADRuns.vehicleLabel(vehicle), + enabled = state.fleet[id] == true, + -- optaget: vores egen opgave, et Smart Pickup-flow, eller AutoDrive/Courseplay kører den allerede + -- (fx spillerens egen parkeringstur) — den må ikke kapres midt i en anden kørsel + busy = state.assignments[id] ~= nil or ADRunsController.getRunForVehicle(vehicle) ~= nil + or isAdActive(vehicle) or ADCpBridge.isWorking(vehicle), + controlled = isControlledByPlayer(vehicle), + width = ADCpBridge.getWorkWidth(vehicle), x = x, z = z, + } + if role == "harvester" then rig.fruits = ADCpBridge.getHarvestFruits(vehicle) or {} end + if role == "seeder" then + rig.seedFruits = ADCpBridge.getSeedFruits(vehicle) + rig.hasSeeds = ADCpBridge.hasSeeds(vehicle) + end + if role == "weeder" then rig.mechanical = ADCpBridge.isMechanicalWeeder(vehicle) end + table.insert(rigs, rig) + end + end + return rigs +end + +-- ---- lager ---- + +-- Gårdens gæringssiloer for markens skår, nærmest marken først (produktioner skår -> SILAGE). +function ADFieldWork.getFermenters(adEnv, farmId, windrowFillType, x, z) + local silage = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeIndexByName("SILAGE") or nil + local result = {} + for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do + local spec = building.kind == "factory" and building.placeable ~= nil and building.placeable.spec_productionPoint or nil + local point = spec ~= nil and spec.productionPoint or nil + if point ~= nil and ADFieldModel.isFermenter(point.productions, windrowFillType, silage) then + local bx, _, bz = getWorldTranslation(building.placeable.rootNode) + table.insert(result, {building = building, distance = (bx - (x or bx)) ^ 2 + (bz - (z or bz)) ^ 2}) + end + end + table.sort(result, function(a, b) return a.distance < b.distance end) + local buildings = {} + for _, entry in ipairs(result) do table.insert(buildings, entry.building) end + return buildings +end + +local function unloadMarkerIds(buildings) + local markerIds = {} + for _, building in ipairs(buildings) do + for _, marker in ipairs(ADBuildings.getMarkers(building, "unload") or {}) do + table.insert(markerIds, marker.id) + end + end + return markerIds +end + +-- Hvilken vare markens trin fører til lager: skår (ensilage), tørskår (hø) eller høstet afgrøde. +function ADFieldWork.storeFillType(field, info) + if field.stage == "harvesting" then return info.harvestFillType end + local product = ADFieldModel.product(field.plan, field.kind or info.kind) + return product == "hay" and info.hay or info.windrow +end + +-- Tvunget lager -> de valgte; ensilage -> gæringssiloer før almindelige siloer (in-game 25/9: græs endte +-- i en Farma-silo); hø og høst -> kun siloer (gæringssiloer laver også hø om til ensilage). +function ADFieldWork.chooseStore(adEnv, field, info, vehicle, fillType) + local isReachable = function(markerId) return ADUnloadWait.hasRouteTo(adEnv, vehicle, markerId) end + local farmId = vehicle:getOwnerFarmId() + if #field.plan.storeBuildingIds > 0 then + local buildings = {} + for _, buildingId in ipairs(field.plan.storeBuildingIds) do + local building = ADBuildings.find(adEnv, farmId, buildingId) + if building ~= nil then table.insert(buildings, building) end + end + return ADLoadSwap.findForcedDumpMarker(adEnv, vehicle, fillType, 1, unloadMarkerIds(buildings), isReachable) + end + local product = ADFieldModel.product(field.plan, field.kind or info.kind) + if field.stage == "collecting" and product == "silage" then + local fermenters = ADFieldWork.getFermenters(adEnv, farmId, fillType, info.x, info.z) + if #fermenters > 0 then + local store, why = ADLoadSwap.findForcedDumpMarker(adEnv, vehicle, fillType, 1, unloadMarkerIds(fermenters), isReachable) + if store ~= nil then return store end + log(field.id, "ingen gæringssilo kan tage græsset (%s) — bruger almindelig silo", tostring(why)) + end + end + return ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, 1, info.markerId, isReachable, nil, nil, false) +end + +-- ---- kurser ---- + +local function coursesOf(fieldId) + ADFieldJobs.courses[fieldId] = ADFieldJobs.courses[fieldId] or {} + return ADFieldJobs.courses[fieldId] +end + +-- Kurset et trin skal genbruge ("mower"/"rake"): gemt kopi, ellers maskinens nuværende kursus. +local function sourceCourse(adEnv, field, source) + local saved = coursesOf(field.id)[source] + if saved ~= nil then return saved end + local vehicleId = source == "rake" and field.rakeVehicleId or field.mowVehicleId + if vehicleId == nil then return nil end + for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do + if ADRunsController.getVehicleId(vehicle) == vehicleId then return ADCpBridge.getCourse(vehicle) end + end + return nil +end + +local function copyCourse(course) + if course ~= nil and course.copy ~= nil then + local ok, copy = pcall(course.copy, course) + if ok and copy ~= nil then return copy end + end + return course +end + +local function rememberCourse(field, key, vehicle) + coursesOf(field.id)[key] = copyCourse(ADCpBridge.getCourse(vehicle)) +end + +-- ---- udsendelse ---- + +local function restoreToolOffset(assignment) + if assignment.restoreToolOffset ~= nil then + ADCpBridge.setToolOffset(assignment.vehicle, assignment.restoreToolOffset) + assignment.restoreToolOffset = nil + end +end + +function ADFieldWork.release(assignment, phase) + restoreToolOffset(assignment) + if assignment.handle ~= nil then assignment.handle.cancelled = true end + assignment.phase = phase or "released" + ADFieldJobs.state.assignments[assignment.vehicleId] = nil +end + +local function fail(fieldId, text) + note(fieldId, {retryAt = now() + ADFieldModel.RETRY_MS, reason = {text = tostring(text)}}) + log(fieldId, "%s", tostring(text)) +end + +-- Hvad et sæt skal kunne ud over rollen (afgrøde, frø, ukrudtets størrelse). +local function acceptFor(field, info, role) + if role == "harvester" then + local fruit = info.fruitIndex + return function(candidate) return fruit ~= nil and candidate.fruits ~= nil and candidate.fruits[fruit] == true end + elseif role == "seeder" then + local fruitIndex = ADFieldWork.sowFruitIndex(field) + return function(candidate) return candidate.hasSeeds and fruitIndex ~= nil and candidate.seedFruits[fruitIndex] == true end + elseif role == "weeder" then + local large = (info.weed or 0) > ADFieldModel.WEED_MECHANICAL_MAX + return function(candidate) return not (large and candidate.mechanical) end + end + return nil +end + +function ADFieldWork.sowFruitIndex(field) + local name = ADFieldModel.sowFruit(field) + if name == nil or g_fruitTypeManager == nil then return nil end + local fruit = g_fruitTypeManager:getFruitTypeByName(name) + return fruit ~= nil and fruit.index or nil +end + +-- Kan trinnet overhovedet begynde? (såning: afgrøde valgt og i såperioden) +local function preflight(field, role) + if role == "seeder" then + local fruitIndex = ADFieldWork.sowFruitIndex(field) + if fruitIndex == nil then return false, {key = "spu_field_reason_noSowFruit"} end + if not ADCpBridge.isPlantableNow(fruitIndex) then + return false, {key = "spu_field_reason_notPlantable", name = ADFieldModel.sowFruit(field)} + end + end + return true +end + +function ADFieldWork.dispatch(adEnv, field, info, decision) + local role = decision.role + local ok, reason = preflight(field, role) + if not ok then + note(field.id, {retryAt = now() + ADFieldModel.RETRY_MS, reason = reason}) + return + end + local candidates = ADFieldWork.getRigs(adEnv) + local required = ADFieldModel.requiredWidth(field, role) + local accept = acceptFor(field, info, role) + local rigs, why, args + if (decision.count or 1) > 1 then + rigs, why, args = ADFieldFleet.pickMany(candidates, role, decision.count, info.x, info.z, required, accept) + else + local rig + rig, why, args = ADFieldFleet.pick(candidates, role, info.x, info.z, required, accept) + rigs = rig ~= nil and {rig} or nil + end + if rigs == nil then + note(field.id, {reason = {key = "spu_field_reason_" .. tostring(why), role = role, args = args}}) + return + end + local count = #rigs + local lanes = ADFieldModel.lanePositions(count) + for index, rig in ipairs(rigs) do + local assignment = { + vehicleId = rig.id, vehicle = rig.vehicle, fieldId = field.id, role = role, stage = field.stage, + helper = decision.helper == true, phase = "driving", since = now(), width = rig.width, + groupSize = decision.helper and 1 or count, lane = count > 1 and lanes[index] or nil, leader = index == 1, + fillType = decision.helper and info.harvestFillType or nil, + } + ADFieldJobs.state.assignments[rig.id] = assignment + startAd(adEnv, rig.vehicle, adEnv.AutoDrive.MODE_DRIVETO, info.markerId, nil) + log(field.id, "%s '%s' sendt til %s (%.1f m%s)", role, rig.label, tostring(info.markerName), rig.width or 0, + assignment.lane ~= nil and string.format(", bane %d", assignment.lane) or "") + end + note(field.id, {reason = false}) +end + +-- Læssevogn: AutoDrive overtager ved "fuld" (Hent og aflever F -> lager, hjælper = Courseplay). +local function prepareCollector(adEnv, field, info, assignment) + local vehicle = assignment.vehicle + local store, why = ADFieldWork.chooseStore(adEnv, field, info, vehicle, ADFieldWork.storeFillType(field, info)) + if store == nil then + ADFieldWork.release(assignment) + fail(field.id, "intet lager: " .. tostring(why)) + return false + end + assignment.storeId = store.id + local module = stateModule(vehicle) + module:setMode(adEnv.AutoDrive.MODE_PICKUPANDDELIVER) + module:setFirstMarkerByWayPointId(info.markerId) + module:setSecondMarkerByWayPointId(store.id) + module:setStartHelper(true) + module.usedHelper = helperCp(adEnv) + enableParkAtJobFinished(vehicle) + module:raiseDirtyFlag() + log(field.id, "læssevogn '%s' aflæsser i %s", ADRuns.vehicleLabel(vehicle), tostring(store.name)) + return true +end + +-- Aflæsningsvogn under høst: Courseplays combine-unloader med stationen fra silovalget. +local function beginUnloader(adEnv, field, info, assignment) + local vehicle = assignment.vehicle + local fillType = assignment.fillType or info.harvestFillType + local store, why = ADFieldWork.chooseStore(adEnv, field, info, vehicle, fillType) + if store == nil then + ADFieldWork.release(assignment) + fail(field.id, "intet lager til høsten: " .. tostring(why)) + return + end + assignment.storeId = store.id + local station = ADSmartPickup.getUnloadStationAtWayPointId(adEnv, store.id, vehicle:getOwnerFarmId()) + local markerPoint = adEnv.ADGraphManager:getWayPointById(info.markerId) + local point = info.points[1] or {x = info.x, z = info.z} + assignment.phase = "starting" + assignment.since = now() + local handle, err = ADCpBridge.startUnloader(vehicle, point.x, point.z, markerPoint ~= nil and markerPoint.x or point.x, + markerPoint ~= nil and markerPoint.z or point.z, station, function(ok, reason) + if ok then + assignment.phase = "working" + assignment.since = now() + log(field.id, "aflæsningsvogn '%s' følger høsteren -> %s", ADRuns.vehicleLabel(vehicle), tostring(store.name)) + else + ADFieldWork.release(assignment) + fail(field.id, "aflæsningsvogn: " .. tostring(reason)) + end + end) + if handle == nil then + ADFieldWork.release(assignment) + fail(field.id, "aflæsningsvogn: " .. tostring(err)) + return + end + assignment.handle = handle +end + +-- Multi-tool: følgere venter på lederens kursus for trinnet og kopierer det med deres bane. +local function courseOptions(adEnv, field, assignment) + local source = ADFieldModel.courseSource(field, assignment.role) + if source ~= nil then + local course = sourceCourse(adEnv, field, source) + if course == nil then + log(field.id, "intet %s-kursus kendt — Courseplay laver et nyt kursus til %s", source, assignment.role) + end + return {course = course, lane = assignment.lane}, source + end + if (assignment.groupSize or 1) > 1 then + if assignment.leader then + return {multiTools = assignment.groupSize, lane = assignment.lane}, nil + end + local shared = coursesOf(field.id)[field.stage] + if shared == nil then return nil, nil end + return {course = shared, lane = assignment.lane}, "multi" + end + return {}, nil +end + +local function beginWork(adEnv, field, info, assignment) + local vehicle = assignment.vehicle + if assignment.role == "unloader" then return beginUnloader(adEnv, field, info, assignment) end + local opts, source = courseOptions(adEnv, field, assignment) + if opts == nil then + -- følger uden lederens kursus endnu + if assignment.phase ~= "waitingCourse" then + assignment.phase = "waitingCourse" + assignment.since = now() + end + return + end + if assignment.role == "collector" and not prepareCollector(adEnv, field, info, assignment) then return end + if assignment.role == "seeder" then + local okSeed, seedError = ADCpBridge.setSeedFruit(vehicle, ADFieldWork.sowFruitIndex(field)) + if not okSeed then + ADFieldWork.release(assignment) + fail(field.id, "såmaskine: " .. tostring(seedError)) + return + end + end + if source == "rake" and field.windrowOffset ~= nil and math.abs(field.windrowOffset) > 0.05 then + local own = ADCpBridge.getToolOffset(vehicle) + local target = ADFieldModel.collectorOffset(own, field.windrowOffset) + if ADCpBridge.setToolOffset(vehicle, target) then + assignment.restoreToolOffset = own + log(field.id, "skår ligger %.1f m %s for rivens linje -> læssevognens forskydning %.1f m (var %.1f m)", + math.abs(field.windrowOffset), field.windrowOffset > 0 and "til venstre" or "til højre", target, own) + end + end + local point = info.points[1] or {x = info.x, z = info.z} + 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() + if assignment.role == "mower" then + note(field.id, {mowWidth = assignment.width, mowVehicleId = assignment.vehicleId}) + rememberCourse(field, "mower", vehicle) + elseif assignment.role == "rake" then + note(field.id, {rakeVehicleId = assignment.vehicleId}) + rememberCourse(field, "rake", vehicle) + end + if assignment.leader and (assignment.groupSize or 1) > 1 then + rememberCourse(field, field.stage, vehicle) + end + log(field.id, "Courseplay startet (%s%s%s)", assignment.role, + reason == "copied" and (", " .. tostring(source) .. "-kursus kopieret") or "", + assignment.lane ~= nil and string.format(", bane %d", assignment.lane) or "") + else + ADFieldWork.release(assignment) + fail(field.id, "Courseplay fejlede: " .. tostring(reason)) + end + end, opts) + if handle == nil then + ADFieldWork.release(assignment) + fail(field.id, "Courseplay kunne ikke startes: " .. tostring(err)) + return + end + assignment.handle = handle +end + +-- Sættet er færdigt: rest til lager (læsse-/aflæsningsvogn), ellers parkering. +local function finishAssignment(adEnv, assignment) + local vehicle = assignment.vehicle + log(assignment.fieldId, "%s '%s' færdig", assignment.role, ADRuns.vehicleLabel(vehicle)) + -- læssevognen parkeres af AutoDrive selv (onCpFinished med parkering sat) + local park = assignment.role ~= "collector" and parkMarkerId(adEnv, vehicle) or nil + if park ~= nil then startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DRIVETO, park, nil) end + restoreToolOffset(assignment) + assignment.phase = "finished" + table.insert(ADFieldJobs.finished, assignment) + ADFieldJobs.state.assignments[assignment.vehicleId] = nil +end + +local function followWorking(adEnv, field, assignment) + local vehicle = assignment.vehicle + if assignment.role == "rake" and field.windrowOffset == nil and now() - assignment.since >= ADFieldWork.WINDROW_MEASURE_MS + and ADCpBridge.isWorking(vehicle) then + local offset = ADCpBridge.getWindrowOffset(vehicle) + note(assignment.fieldId, {windrowOffset = offset or 0}) + log(assignment.fieldId, offset ~= nil and "river lægger skåret %.1f m fra sin kursuslinje (venstre +)" or "river: nedlægningsområde ikke fundet — regner med midten (%.1f m)", offset or 0) + end + if ADCpBridge.isWorking(vehicle) or isAdActive(vehicle) then + assignment.idleSince = nil + return + end + assignment.idleSince = assignment.idleSince or now() + if now() - assignment.idleSince < ADFieldWork.IDLE_CONFIRM_MS then return end + local rest = assignment.storeId ~= nil and not assignment.deliveredRest and restLoad(adEnv, vehicle) or 0 + if rest > 0 then + -- CP færdig/stoppet med læs på: AD tømmer kun selv med parkering sat, så vi sender resten til lager + assignment.deliveredRest = true + assignment.idleSince = nil + startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_DELIVERTO, assignment.storeId, nil) + log(assignment.fieldId, "'%s' kører rest-læs (%.0f l) til lager", ADRuns.vehicleLabel(vehicle), rest) + return + end + finishAssignment(adEnv, assignment) +end + +function ADFieldWork.follow(adEnv, assignment) + local vehicle = assignment.vehicle + -- Kun når spilleren selv KØRER (bevæger sig uden Courseplay/AutoDrive) slippes sættet — at sidde i + -- førersædet og kigge må ikke slippe det (in-game 25/9). + local steeredByAi = ADCpBridge.isWorking(vehicle) or isAdActive(vehicle) or assignment.phase == "starting" + local speed = vehicle.getLastSpeed ~= nil and vehicle:getLastSpeed() or 0 + if isControlledByPlayer(vehicle) and not steeredByAi and speed > ADFieldWork.PLAYER_DRIVING_KMH then + log(assignment.fieldId, "'%s' frigivet — spilleren kører det", ADRuns.vehicleLabel(vehicle)) + return ADFieldWork.release(assignment) + end + local field = ADFieldJobs.getField(assignment.fieldId) + local info = ADFields.getInfo(assignment.fieldId) + wake(vehicle) + if assignment.phase == "driving" then + if not isAdActive(vehicle) and now() - assignment.since > 2000 then + local x, z = vehiclePosition(vehicle) + local wayPoint = info ~= nil and adEnv.ADGraphManager:getWayPointById(info.markerId) or nil + local distance = wayPoint ~= nil and math.sqrt((wayPoint.x - x) ^ 2 + (wayPoint.z - z) ^ 2) or math.huge + if distance <= ADFieldWork.ARRIVE_DISTANCE then + beginWork(adEnv, field, info, assignment) + else + ADFieldWork.release(assignment) + fail(assignment.fieldId, string.format("%s nåede ikke %s (%.0f m væk)", ADRuns.vehicleLabel(vehicle), tostring(info ~= nil and info.markerName or "?"), distance)) + end + end + elseif assignment.phase == "waitingCourse" then + if now() - assignment.since > ADFieldWork.COURSE_WAIT_MS then + ADFieldWork.release(assignment) + fail(assignment.fieldId, "multi-tool: lederens kursus kom ikke (3 min)") + elseif coursesOf(field.id)[field.stage] ~= nil then + beginWork(adEnv, field, info, assignment) + end + elseif assignment.phase == "starting" then + if now() - assignment.since > ADFieldWork.START_TIMEOUT_MS then + ADFieldWork.release(assignment) + fail(assignment.fieldId, "Courseplay svarede ikke (60 s)") + end + elseif assignment.phase == "working" then + followWorking(adEnv, field, assignment) + end +end + +-- Høsten er færdig: aflæsningsvognene stoppes; har de rest, kører den til lager, ellers parkering. +function ADFieldWork.windDownHelpers(adEnv, fieldId, stage) + for _, assignment in ipairs(ADFieldJobs.assignmentsFor(fieldId)) do + if assignment.helper and assignment.stage == stage then + if assignment.handle ~= nil then assignment.handle.cancelled = true end + ADCpBridge.stop(assignment.vehicle) + assignment.phase = "working" + assignment.idleSince = nil + log(fieldId, "aflæsningsvogn '%s' stoppes — høsten er færdig", ADRuns.vehicleLabel(assignment.vehicle)) + end + end +end + +function ADFieldWork.stopAll(adEnv, fieldId) + for _, assignment in ipairs(ADFieldJobs.assignmentsFor(fieldId)) do + pcall(stopAd, assignment.vehicle) + ADCpBridge.stop(assignment.vehicle) + ADFieldWork.release(assignment) + end +end diff --git a/FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua b/FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua index af3adb0..6e5197b 100644 --- a/FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua +++ b/FS25_ADSmartPickup/gui/SmartPickupFieldTab.lua @@ -14,7 +14,7 @@ SmartPickupFrame.ROLE_KEYS = {mower = "spu_field_role_mower", tedder = "spu_fiel function SmartPickupFrame:fieldReasonText(reason) if type(reason) ~= "table" then return nil end if reason.text ~= nil then return reason.text end - local role = reason.role ~= nil and self.i18n:getText(SmartPickupFrame.ROLE_KEYS[reason.role] or "spu_field_role_mower") or "" + local role = reason.name or (reason.role ~= nil and self.i18n:getText(SmartPickupFrame.ROLE_KEYS[reason.role] or "spu_field_role_mower")) or "" local template = self.i18n:getText(reason.key) local args = reason.args or {} local ok, text = pcall(string.format, template, role, args[1] or 0, args[2] or 0) diff --git a/FS25_ADSmartPickup/modDesc.xml b/FS25_ADSmartPickup/modDesc.xml index a74ef37..a558a5a 100644 --- a/FS25_ADSmartPickup/modDesc.xml +++ b/FS25_ADSmartPickup/modDesc.xml @@ -32,6 +32,7 @@ + diff --git a/tests/test_adFieldJobs.lua b/tests/test_adFieldJobs.lua index 1f8f412..b3c5971 100644 --- a/tests/test_adFieldJobs.lua +++ b/tests/test_adFieldJobs.lua @@ -43,5 +43,37 @@ check("J7 slået uden sæt -> videre", J.decide(mowing, {id = 109, markerId = 5, check("J7 stående uden sæt -> slå", J.decide(mowing, info, {}, 0).action, "dispatch") check("J7 slået men sæt i gang -> vent", J.decide(mowing, {id = 109, markerId = 5, mowable = true, readyRatio = 0.1}, {{phase = "working"}}, 0).action, "none") +-- J8: høst — høster først (hovedrolle), aflæsningsvogne kun mens høsteren arbejder +local harvestInfo = {id = 50, markerId = 7, kind = "harvest", readyRatio = 1, fruitIndex = 3} +local harvesting = M.start(M.onReadiness(M.newField(50), 1), "harvest", {}) +local d = J.decide(harvesting, harvestInfo, {}, 0) +check("J8 send høster", d.role, "harvester"); check("J8 ikke hjælper", d.helper, false) +local withHarvester = {{phase = "working", stage = "harvesting", helper = false}} +local dHelper = J.decide(harvesting, harvestInfo, withHarvester, 0) +check("J8 så aflæsningsvogn", dHelper.role, "unloader"); check("J8 hjælper", dHelper.helper, true) +check("J8 vogn på plads -> intet", J.decide(harvesting, harvestInfo, {withHarvester[1], {phase = "working", stage = "harvesting", helper = true}}, 0).action, "none") +-- høsteren færdig, afgrøden væk (marken er nu bar) -> videre, selv om vognen stadig kører +local afterInfo = {id = 50, markerId = 7, kind = "bare", readyRatio = 0} +check("J8 høst færdig -> videre", J.decide(harvesting, afterInfo, {{phase = "finished", stage = "harvesting"}, {phase = "working", stage = "harvesting", helper = true}}, 0).action, "advance") +-- sæt fra et tidligere trin (vogn på vej hjem) tæller ikke på det nye trin +local cultivating = M.advance(harvesting, {}) +check("J8 gammel vogn tæller ikke", J.decide(cultivating, afterInfo, {{phase = "working", stage = "harvesting", helper = true}}, 0).role, "cultivator") + +-- J9: multi-tool — hele holdet sendes samlet; mens nogen kører, sendes ingen nye hovedsæt +local multiMow = M.with(mowing, {plan = {rigs = 3}}) +local dMulti = J.decide(multiMow, info, {}, 0) +check("J9 tre slåmaskiner", dMulti.count, 3) +check("J9 én kører stadig -> ingen nye", J.decide(multiMow, info, {{phase = "working"}, {phase = "finished"}}, 0).action, "none") + +-- J10: pleje under vækst — Auto starter, Manuel viser behovet +local growingInfo = {id = 60, markerId = 8, kind = "harvest", growing = true, fert = 0, fertMax = 2, lime = 2, weed = 0, readyRatio = 0.2} +local autoField = M.with(M.newField(60), {plan = {mode = "auto"}}) +local care = J.decide(autoField, growingInfo, {}, 0) +check("J10 auto -> pleje", care.action, "care"); check("J10 gødning", care.stage, "fertilizing") +local manualCare = J.decide(M.newField(60), growingInfo, {}, 0) +check("J10 manuel -> intet, men behov vist", manualCare.action, "none"); check("J10 behov", manualCare.care, "fertilizing") +check("J10 Start nu -> pleje", J.decide(M.with(M.newField(60), {startRequested = true}), growingInfo, {}, 0).action, "care") +check("J10 uden markør -> vent", J.decide(autoField, {id = 60, kind = "harvest", growing = true, fert = 0, fertMax = 2}, {}, 0).action, "wait") + print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) os.exit(failures == 0 and 0 or 1)