diff --git a/config/VehicleConfigurations.xml b/config/VehicleConfigurations.xml
index 1d575c678..b4cb19eed 100644
--- a/config/VehicleConfigurations.xml
+++ b/config/VehicleConfigurations.xml
@@ -494,9 +494,6 @@ You can define the following custom settings:
-
-
+
diff --git a/modDesc.xml b/modDesc.xml
index a24cfce9b..01321fedb 100644
--- a/modDesc.xml
+++ b/modDesc.xml
@@ -211,6 +211,7 @@ Changelog 8.1.0.3
+
@@ -346,6 +347,7 @@ Changelog 8.1.0.3
+
@@ -434,6 +436,10 @@ Changelog 8.1.0.3
+
+
+
+
@@ -463,6 +469,7 @@ Changelog 8.1.0.3
+
\ No newline at end of file
diff --git a/scripts/ai/CollisionAvoidanceController.lua b/scripts/ai/CollisionAvoidanceController.lua
index f37b5f255..6047bc40d 100644
--- a/scripts/ai/CollisionAvoidanceController.lua
+++ b/scripts/ai/CollisionAvoidanceController.lua
@@ -61,25 +61,28 @@ function CollisionAvoidanceController:findPotentialCollisions()
if AIDriveStrategyCombineCourse.isActiveCpCombine(vehicle) then
local d = calcDistanceFrom(self.vehicle.rootNode, vehicle.rootNode)
if d < self.range then
- local myCourse = self.strategy:getCurrentCourse()
- local otherCourse = vehicle:getCpDriveStrategy():getCurrentCourse()
- local myDistanceToCollision, otherDistanceToCollision = myCourse:intersects(otherCourse, self.lookahead, true)
- if myDistanceToCollision then
- -- our course intersects with this vehicle's course (lastSpeedReal is in m/ms)
- -- for our own ETE, we always use the field speed and not the actual speed. This is to make sure
- -- we come to a full stop on a warning and remain stopped while the warning is active
- local myEte = myDistanceToCollision / (self.strategy:getFieldSpeed())
- local otherEte = CpMathUtil.divide(otherDistanceToCollision, (vehicle.lastSpeedReal * 1000))
- -- self:debug('Checking %s at %.1f m, %.1f, ETE %.1f %.1f', CpUtil.getName(vehicle), d, myDistanceToCollision, myEte, otherEte)
- if math.abs(myEte - otherEte) < self.eteDiffThreshold then
- if not self.warning:get() or (self.warning:get() and vehicle ~= self.warningVehicle) then
- -- no warning is active yet, or there is, but this is a different vehicle
- self:debug('collision warning: my course intersects with %s in %.1f m, my ETE %.1f, other ETE %.1f',
- CpUtil.getName(vehicle), myDistanceToCollision, myEte, otherEte)
+ local otherStrategy = vehicle:getCpDriveStrategy()
+ if otherStrategy then
+ local myCourse = self.strategy:getCurrentCourse()
+ local otherCourse = otherStrategy:getCurrentCourse()
+ local myDistanceToCollision, otherDistanceToCollision = myCourse:intersects(otherCourse, self.lookahead, true)
+ if myDistanceToCollision then
+ -- our course intersects with this vehicle's course (lastSpeedReal is in m/ms)
+ -- for our own ETE, we always use the field speed and not the actual speed. This is to make sure
+ -- we come to a full stop on a warning and remain stopped while the warning is active
+ local myEte = myDistanceToCollision / (self.strategy:getFieldSpeed())
+ local otherEte = CpMathUtil.divide(otherDistanceToCollision, (vehicle.lastSpeedReal * 1000))
+ -- self:debug('Checking %s at %.1f m, %.1f, ETE %.1f %.1f', CpUtil.getName(vehicle), d, myDistanceToCollision, myEte, otherEte)
+ if math.abs(myEte - otherEte) < self.eteDiffThreshold then
+ if not self.warning:get() or (self.warning:get() and vehicle ~= self.warningVehicle) then
+ -- no warning is active yet, or there is, but this is a different vehicle
+ self:debug('collision warning: my course intersects with %s in %.1f m, my ETE %.1f, other ETE %.1f',
+ CpUtil.getName(vehicle), myDistanceToCollision, myEte, otherEte)
+ end
+ self.warningVehicle = vehicle
+ self.warning:set(true, self.clearWarningDelayMs)
+ return
end
- self.warningVehicle = vehicle
- self.warning:set(true, self.clearWarningDelayMs)
- return
end
end
end
diff --git a/scripts/ai/CpManualCombineProxy.lua b/scripts/ai/CpManualCombineProxy.lua
new file mode 100644
index 000000000..531189827
--- /dev/null
+++ b/scripts/ai/CpManualCombineProxy.lua
@@ -0,0 +1,389 @@
+--- Proxy that mimics the AIDriveStrategyCombineCourse interface for manually-driven
+--- combines, so the existing unloader strategy can interact with them without nil checks.
+---@class CpManualCombineProxy
+CpManualCombineProxy = CpObject()
+
+CpManualCombineProxy.activeCalls = {}
+CpManualCombineProxy.DYNAMIC_COURSE_LENGTH = 100
+CpManualCombineProxy.COURSE_REFRESH_INTERVAL = 2000
+
+function CpManualCombineProxy:init(vehicle)
+ self.vehicle = vehicle
+ self.unloader = CpTemporaryObject(nil)
+ self.timeToCallUnloader = CpTemporaryObject(true)
+ self.dynamicCourse = nil
+ self.lastCourseRefreshTime = 0
+ self.measuredBackDistance = 5
+
+ self:findPipeImplement()
+ self:measureBackDistance()
+ self:refreshDynamicCourse()
+
+ CpManualCombineProxy.activeCalls[vehicle] = self
+end
+
+function CpManualCombineProxy:delete()
+ CpManualCombineProxy.activeCalls[self.vehicle] = nil
+ self.dynamicCourse = nil
+end
+
+function CpManualCombineProxy:findPipeImplement()
+ self.pipeImplement = nil
+ self.pipeSpec = nil
+ for _, childVehicle in ipairs(self.vehicle:getChildVehicles()) do
+ if childVehicle.spec_pipe then
+ self.pipeImplement = childVehicle
+ self.pipeSpec = childVehicle.spec_pipe
+ break
+ end
+ end
+ if not self.pipeSpec and self.vehicle.spec_pipe then
+ self.pipeImplement = self.vehicle
+ self.pipeSpec = self.vehicle.spec_pipe
+ end
+end
+
+function CpManualCombineProxy:measureBackDistance()
+ local backMarkerNode, _, _, backMarkerOffset = Markers.getBackMarkerNode(self.vehicle)
+ if backMarkerOffset then
+ self.measuredBackDistance = math.abs(backMarkerOffset)
+ end
+end
+
+--- Generates a straight course from the combine's current position and heading.
+function CpManualCombineProxy:refreshDynamicCourse()
+ self.dynamicCourse = Course.createStraightForwardCourse(self.vehicle,
+ self.DYNAMIC_COURSE_LENGTH, 0, self.vehicle:getAIDirectionNode())
+ self.lastCourseRefreshTime = g_time
+end
+
+function CpManualCombineProxy:update(dt)
+ if g_time - self.lastCourseRefreshTime > self.COURSE_REFRESH_INTERVAL then
+ self:refreshDynamicCourse()
+ end
+ self:callUnloaderWhenNeeded()
+end
+
+------------------------------------------------------------------------------------------------------------------------
+-- Unloader calling: simplified version that always calls with the combine's current position
+------------------------------------------------------------------------------------------------------------------------
+function CpManualCombineProxy:callUnloaderWhenNeeded()
+ if not self.timeToCallUnloader:get() then
+ return
+ end
+ self.timeToCallUnloader:set(false, 1500)
+
+ if self.unloader:get() then
+ return
+ end
+
+ local bestUnloader = self:findUnloader()
+ if bestUnloader then
+ local strategy = bestUnloader:getCpDriveStrategy()
+ if strategy and strategy.call then
+ strategy:call(self.vehicle, nil)
+ end
+ end
+end
+
+function CpManualCombineProxy:findUnloader()
+ local bestScore = -math.huge
+ local bestUnloader
+ for _, vehicle in pairs(g_currentMission.vehicleSystem.vehicles) do
+ if AIDriveStrategyUnloadCombine.isActiveCpCombineUnloader(vehicle) then
+ local x, _, z = getWorldTranslation(self.vehicle.rootNode)
+ local driveStrategy = vehicle:getCpDriveStrategy()
+ if driveStrategy:isServingPosition(x, z, 10) then
+ local fillPct = driveStrategy:getFillLevelPercentage()
+ if driveStrategy:isAllowedToBeCalled() and fillPct < 99 then
+ local dist, _ = driveStrategy:getDistanceAndEteToVehicle(self.vehicle)
+ local score = fillPct - 0.1 * dist
+ if score > bestScore then
+ bestUnloader = vehicle
+ bestScore = score
+ end
+ end
+ end
+ end
+ end
+ return bestUnloader
+end
+
+------------------------------------------------------------------------------------------------------------------------
+-- Interface methods that mimic AIDriveStrategyCombineCourse for the unloader
+------------------------------------------------------------------------------------------------------------------------
+
+function CpManualCombineProxy:registerUnloader(driver)
+ self.unloader:set(driver, 1000)
+end
+
+function CpManualCombineProxy:deregisterUnloader(driver, noEventSend)
+ self.unloader:reset()
+end
+
+function CpManualCombineProxy:hasAutoAimPipe()
+ if self.pipeSpec then
+ return self.pipeSpec.numAutoAimingStates > 0
+ end
+ return false
+end
+
+function CpManualCombineProxy:getFillType()
+ if self.pipeImplement and self.pipeImplement.getDischargeNodeByIndex then
+ local dischargeNode = self.pipeImplement:getDischargeNodeByIndex(
+ self.pipeImplement:getPipeDischargeNodeIndex())
+ if dischargeNode then
+ return self.pipeImplement:getFillUnitFillType(dischargeNode.fillUnitIndex)
+ end
+ end
+ return FillType.UNKNOWN
+end
+
+function CpManualCombineProxy:isDischarging()
+ if self.pipeImplement and self.pipeImplement.getDischargeState then
+ return self.pipeImplement:getDischargeState() ~= Dischargeable.DISCHARGE_STATE_OFF
+ end
+ return false
+end
+
+function CpManualCombineProxy:getPipeOffset(additionalOffsetX, additionalOffsetZ)
+ local pipeOffsetX, pipeOffsetZ = 0, 0
+ if self.pipeSpec then
+ local pipeNode = self.pipeSpec.nodes and self.pipeSpec.nodes[1]
+ if pipeNode and pipeNode.node and entityExists(pipeNode.node) then
+ pipeOffsetX, _, pipeOffsetZ = localToLocal(pipeNode.node,
+ self.vehicle:getAIDirectionNode(), 0, 0, 0)
+ else
+ pipeOffsetX = self.vehicle:getCpSettings().pipeOffsetX:getValue()
+ pipeOffsetZ = self.vehicle:getCpSettings().pipeOffsetZ:getValue()
+ end
+ end
+ return pipeOffsetX + (additionalOffsetX or 0), pipeOffsetZ + (additionalOffsetZ or 0), self:hasAutoAimPipe()
+end
+
+function CpManualCombineProxy:isPipeMoving()
+ if self.pipeSpec then
+ return self.pipeSpec.currentState == 0
+ end
+ return false
+end
+
+function CpManualCombineProxy:getPipeOffsetReferenceNode()
+ return self.vehicle:getAIDirectionNode()
+end
+
+function CpManualCombineProxy:getAreaToAvoid()
+ return nil
+end
+
+function CpManualCombineProxy:getMeasuredBackDistance()
+ return self.measuredBackDistance
+end
+
+function CpManualCombineProxy:getFieldworkCourse()
+ return self.dynamicCourse
+end
+
+function CpManualCombineProxy:getClosestFieldworkWaypointIx()
+ return 1
+end
+
+function CpManualCombineProxy:getWorkWidth()
+ if self.vehicle.getCpSettings then
+ local settings = self.vehicle:getCpSettings()
+ if settings and settings.workWidth then
+ return settings.workWidth:getValue()
+ end
+ end
+ return 6
+end
+
+function CpManualCombineProxy:getFruitAtSides()
+ return nil, nil
+end
+
+------------------------------------------------------------------------------------------------------------------------
+-- State queries: safe defaults for a manually-driven combine
+------------------------------------------------------------------------------------------------------------------------
+
+function CpManualCombineProxy:isWaitingForUnload()
+ -- Return true only when the combine is actually stopped so the unloader strategy
+ -- transitions correctly between moving-combine and stopped-combine unload states,
+ -- and so the deadlock detector does not misfire when the grain cart is simply pausing.
+ return AIUtil.isStopped(self.vehicle)
+end
+
+function CpManualCombineProxy:isWaitingForUnloadAfterPulledBack()
+ return false
+end
+
+function CpManualCombineProxy:isWaitingForUnloadAfterCourseEnded()
+ return false
+end
+
+function CpManualCombineProxy:willWaitForUnloadToFinish()
+ local stopped = AIUtil.isStopped(self.vehicle)
+ if stopped then
+ -- Debounce: only report "waiting" after the combine has been stopped for 3+ continuous seconds.
+ -- A momentary GPS micro-correction or terrain hitch would otherwise cause isInFrontAndAligned-
+ -- ToMovingCombine() to return false, which combined with the grain cart being in the normal
+ -- forward-of-direction-node unloading position (dz>0) kills isBehindAndAlignedToCombine() too,
+ -- triggering the dreaded startWaitingForSomethingToDo() → tarp cycle.
+ if not self._stoppedSinceTime then
+ self._stoppedSinceTime = g_time
+ CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle,
+ 'CpManualCombineProxy:willWaitForUnloadToFinish: combine stopped, starting 3s debounce')
+ end
+ local waitingLongEnough = g_time - self._stoppedSinceTime > 3000
+ if waitingLongEnough then
+ CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle,
+ 'CpManualCombineProxy:willWaitForUnloadToFinish: combine stopped >3s, returning true')
+ end
+ return waitingLongEnough
+ else
+ if self._stoppedSinceTime then
+ CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle,
+ 'CpManualCombineProxy:willWaitForUnloadToFinish: combine moving again (was stopped %.1fs)',
+ (g_time - self._stoppedSinceTime) / 1000)
+ end
+ self._stoppedSinceTime = nil
+ return false
+ end
+end
+
+function CpManualCombineProxy:alwaysNeedsUnloader()
+ return false
+end
+
+function CpManualCombineProxy:isReadyToUnload(noUnloadWithPipeInFruit)
+ return true
+end
+
+function CpManualCombineProxy:isTurning()
+ return false
+end
+
+function CpManualCombineProxy:isTurningButNotEndingTurn()
+ return false
+end
+
+function CpManualCombineProxy:isTurnForwardOnly()
+ return false
+end
+
+function CpManualCombineProxy:getTurnCourse()
+ return nil
+end
+
+function CpManualCombineProxy:isFinishingRow()
+ return false
+end
+
+function CpManualCombineProxy:isAboutToTurn()
+ return false
+end
+
+function CpManualCombineProxy:isAboutToReturnFromPocket()
+ return false
+end
+
+function CpManualCombineProxy:isManeuvering()
+ return false
+end
+
+function CpManualCombineProxy:isOnHeadland(n)
+ return false
+end
+
+function CpManualCombineProxy:isReversing()
+ return false
+end
+
+function CpManualCombineProxy:isIdle()
+ return false
+end
+
+function CpManualCombineProxy:hold(ms)
+end
+
+function CpManualCombineProxy:requestToIgnoreProximity(vehicle)
+end
+
+function CpManualCombineProxy:requestToMoveForward(requestingVehicle)
+end
+
+function CpManualCombineProxy:reconfirmRendezvous()
+end
+
+function CpManualCombineProxy:hasRendezvousWith(unloader)
+ return false
+end
+
+function CpManualCombineProxy:getTurnArea()
+ return nil, nil
+end
+
+function CpManualCombineProxy:isUnloadFinished()
+ if self:isDischarging() then
+ self._wasDischarging = true
+ self._dischargeOffTime = nil
+ return false
+ end
+ if self._wasDischarging then
+ -- Require discharge to be off for 2 continuous seconds before declaring done.
+ -- This prevents a momentary aim-miss (grain cart swerve, brief obstruction) from
+ -- prematurely ending the unload cycle and forcing the grain cart to drive away and return.
+ if not self._dischargeOffTime then
+ self._dischargeOffTime = g_time
+ CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle,
+ 'CpManualCombineProxy:isUnloadFinished: discharge stopped, starting 2s debounce')
+ end
+ local elapsed = g_time - self._dischargeOffTime
+ if elapsed > 2000 then
+ CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle,
+ 'CpManualCombineProxy:isUnloadFinished: discharge off for %.1fs, returning TRUE (unload done)', elapsed / 1000)
+ self._wasDischarging = false
+ self._dischargeOffTime = nil
+ return true
+ end
+ CpUtil.debugVehicle(CpDebug.DBG_UNLOAD, self.vehicle,
+ 'CpManualCombineProxy:isUnloadFinished: discharge off for %.1fs, waiting for 2s debounce', elapsed / 1000)
+ end
+ return false
+end
+
+function CpManualCombineProxy:getFillLevelPercentage()
+ -- The farmer is in full control. Fill level must never cause the grain cart to leave —
+ -- the only valid exit is the pipe closing (isUnloadFinished). Always report full.
+ return 1
+end
+
+function CpManualCombineProxy:isTurningOnHeadland()
+ return false
+end
+
+function CpManualCombineProxy:getTurnStartWpIx()
+ return 1
+end
+
+function CpManualCombineProxy:isProcessingFruit()
+ return false
+end
+
+--- Marker so the unloader strategy can identify this as a manual-combine proxy
+--- purely from the strategy object, without re-querying the vehicle.
+function CpManualCombineProxy:isManualProxy()
+ return true
+end
+
+function CpManualCombineProxy:isWaitingInPocket()
+ return false
+end
+
+function CpManualCombineProxy:isActiveCpCombine()
+ return true
+end
+
+function CpManualCombineProxy:getUnloadTargetType()
+ return nil
+end
diff --git a/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua b/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua
index eecc77c80..dfa0c466f 100644
--- a/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua
+++ b/scripts/ai/strategies/AIDriveStrategyUnloadCombine.lua
@@ -318,12 +318,53 @@ function AIDriveStrategyUnloadCombine:initializeImplementControllers(vehicle)
self:addImplementController(vehicle, FoldableController, Foldable, {})
end
+--- Gets the combine's drive strategy or manual combine proxy.
+--- Use this instead of combineToUnload:getCpDriveStrategy() to support manual combines.
+---@return AIDriveStrategyCombineCourse|CpManualCombineProxy|nil
+function AIDriveStrategyUnloadCombine:getCombineStrategy()
+ if self.combineToUnload then
+ -- Try the CP drive strategy directly without requiring getIsCpActive() to be true.
+ -- This prevents releasing the combine during brief CP state transitions (e.g. headland turns)
+ -- where the strategy object is still valid but getIsCpActive() momentarily returns false.
+ if self.combineToUnload.getCpDriveStrategy then
+ local strategy = self.combineToUnload:getCpDriveStrategy()
+ if strategy then return strategy end
+ end
+ -- Fall back to the manual combine proxy
+ if self.combineToUnload.cpGetManualCombineProxy then
+ return self.combineToUnload:cpGetManualCombineProxy()
+ end
+ end
+ return nil
+end
+
+--- Checks if the assigned combine is active (either CP-driven or manually calling a grain cart).
+---@return boolean
+function AIDriveStrategyUnloadCombine:isCombineActive()
+ if self.combineToUnload then
+ if self.combineToUnload.getIsCpActive and self.combineToUnload:getIsCpActive() then
+ return true
+ end
+ if self.combineToUnload.cpIsManualCombineCallingUnloader and self.combineToUnload:cpIsManualCombineCallingUnloader() then
+ return true
+ end
+ end
+ return false
+end
+
function AIDriveStrategyUnloadCombine:isProximitySpeedControlEnabled()
- return not (self.state == self.states.UNLOADING_MOVING_COMBINE and self.combineToUnload:getCpDriveStrategy():hasAutoAimPipe())
+ local strategy = self:getCombineStrategy()
+ return not (self.state == self.states.UNLOADING_MOVING_COMBINE and strategy and strategy:hasAutoAimPipe())
end
function AIDriveStrategyUnloadCombine:ignoreProximityObject(object, vehicle, moveForwards, hitTerrain)
return (self.state == self.states.UNLOADING_ON_THE_FIELD and hitTerrain) or
+ -- Straw windrow piles are height-map physics objects. Their raycasts register as terrain
+ -- hits, causing the proximity sensor to bleed speed when crossing perpendicular.
+ -- The pathfinder has already routed around real terrain obstacles, so terrain hits
+ -- during approach and unloading are safe to ignore.
+ (self.state == self.states.DRIVING_TO_COMBINE and hitTerrain) or
+ (self.state == self.states.UNLOADING_MOVING_COMBINE and hitTerrain) or
-- these states handle the proximity by themselves
(self.state == self.states.UNLOADING_MOVING_COMBINE and vehicle == self.combineToUnload) or
(self.state == self.states.HANDLE_CHOPPER_HEADLAND_TURN and vehicle == self.combineToUnload)
@@ -359,8 +400,8 @@ function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ)
end
-- make sure if we have a combine we stay registered
- if self.combineToUnload and self.combineToUnload:getIsCpActive() then
- local strategy = self.combineToUnload:getCpDriveStrategy()
+ if self.combineToUnload and self:isCombineActive() then
+ local strategy = self:getCombineStrategy()
if strategy then
if strategy.registerUnloader then
strategy:registerUnloader(self)
@@ -373,7 +414,7 @@ function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ)
end
end
- if self.combineToUnload == nil or not self.combineToUnload:getIsCpActive() then
+ if self.combineToUnload == nil or not self:isCombineActive() then
if CpUtil.isStateOneOf(self.state, self.combineUnloadStates) then
end
@@ -409,7 +450,7 @@ function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ)
self:setMaxSpeed(0)
elseif self.state == self.states.IDLE then
-- nothing to do right now, wait for one of the following:
- -- - combine calls
+ -- - combine calls (including manual combines via proxy)
-- - user sends us to unload the trailer
-- - a trailer appears where we can unload our auger wagon if full
self:setMaxSpeed(0)
@@ -446,7 +487,7 @@ function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ)
elseif self.state == self.states.UNLOADING_MOVING_COMBINE then
local x, z
- if self.combineToUnload:getCpDriveStrategy():hasAutoAimPipe() then
+ if self:getCombineStrategy():hasAutoAimPipe() then
x, z = self:unloadMovingChopper()
else
x, z = self:unloadMovingCombine(dt)
@@ -487,7 +528,7 @@ function AIDriveStrategyUnloadCombine:getDriveData(dt, vX, vY, vZ)
self:setMaxSpeed(self.settings.reverseSpeed:getValue())
if self.state.properties.holdCombine then
self:debugSparse('Holding combine while backing up')
- self.combineToUnload:getCpDriveStrategy():hold(1000)
+ self:getCombineStrategy():hold(1000)
end
-- drive back until the combine is in front of us
local _, _, dz = self:getDistanceFromCombine(self.state.properties.vehicle)
@@ -544,7 +585,7 @@ end
function AIDriveStrategyUnloadCombine:hasToWaitForAssignedCombine()
if CpUtil.isStateOneOf(self.state, self.combineUnloadStates) then
- return self.combineToUnload == nil or not self.combineToUnload:getIsCpActive() or self.combineToUnload:getCpDriveStrategy() == nil
+ return self.combineToUnload == nil or not self:isCombineActive() or self:getCombineStrategy() == nil
end
return false
end
@@ -572,10 +613,11 @@ function AIDriveStrategyUnloadCombine:startWaitingForSomethingToDo()
end
end
+
---@return table|nil the best node (of all the fill nodes on all trailers) to use to unload a harvester
function AIDriveStrategyUnloadCombine:getBestTargetNode()
local function isValidNode(targetNode)
- local fillType = self.combineToUnload:getCpDriveStrategy():getFillType()
+ local fillType = self:getCombineStrategy():getFillType()
-- for some harvesters (DeWulf), fill type is unknown until they start working
if fillType ~= FillType.UNKNOWN and not targetNode.trailer:getFillUnitAllowsFillType(targetNode.fillUnitIx, fillType) then
self:debugSparse("Fill node %d of trailer %s doesn't accept fillType %s!",
@@ -632,7 +674,7 @@ function AIDriveStrategyUnloadCombine:driveBesideCombine()
return
end
- local strategy = self.combineToUnload:getCpDriveStrategy()
+ local strategy = self:getCombineStrategy()
-- use a factor to make sure we reach the pipe fast, but be more gentle while discharging
local factor = strategy:isDischarging() and 0.75 or 2
local combineSpeed = self.combineToUnload.lastSpeedReal * 3600
@@ -653,12 +695,28 @@ function AIDriveStrategyUnloadCombine:driveBesideCombine()
CpUtil.getName(self.vehicle), dz, speed, factor)
local gx, gy, gz
+ -- For manually-driven combines we cannot rely on a fieldwork course to steer by — there is
+ -- none. Always compute the direct goal point (regardless of dz) so steering is derived from
+ -- the live pipe reference node and stays locked to the combine's current heading, including
+ -- through curves. For CP-driven combines keep the original behaviour: only override the PPC
+ -- course-based goal point when the cart is still far behind the pipe (dz > 5).
+ local isManual = strategy.isManualProxy and strategy:isManualProxy()
-- Calculate an artificial goal point relative to the harvester to align better when starting to unload
- if dz > 5 then
+ if dz > 5 or isManual then
_, _, dz = localToLocal(self.vehicle:getAIDirectionNode(), self:getPipeOffsetReferenceNode(), 0, 0, 0)
+ -- For manual combines: use the vehicle's natural (non-CTE-adjusted) lookahead distance.
+ -- self.ppc:getLookaheadDistance() can be inflated up to 2x the base value when the cart
+ -- is far from the stale placeholder course. A 12 m lookahead puts the goal point too far
+ -- ahead for responsive curve following — a gentle S-curve doesn't register as a heading
+ -- change until the cart has already overshot. normalLookAheadDistance is constant (≈5-6m)
+ -- and is not inflated by cross-track error, so turns are tracked much more tightly.
+ -- For CP-driven combines the inflated lookahead is appropriate (they follow a real course).
+ local lookahead = isManual
+ and (self.ppc.normalLookAheadDistance or 6)
+ or self.ppc:getLookaheadDistance()
gx, gy, gz = localToWorld(self:getPipeOffsetReferenceNode(),
-- straight line parallel to the harvester, under the pipe, look ahead distance from the unloader
- self:getPipeOffset(self.combineToUnload), 0, dz + self.ppc:getLookaheadDistance())
+ self:getPipeOffset(self.combineToUnload), 0, dz + lookahead)
if CpUtil.isVehicleDebugActive(self.vehicle) and CpDebug:isChannelActive(self.debugChannel) then
-- show the goal point
@@ -674,7 +732,8 @@ end
------------------------------------------------------------------------------------------------------------------------
function AIDriveStrategyUnloadCombine:isInDeadlock()
if self.combineToUnload then
- local combineStrategy = self.combineToUnload:getCpDriveStrategy()
+ local combineStrategy = self:getCombineStrategy()
+ if not combineStrategy then return false end
if self.inDeadlock == nil then
self.inDeadlock = CpDelayedBoolean()
end
@@ -703,7 +762,7 @@ function AIDriveStrategyUnloadCombine:unloadMovingChopper()
return
end
- local combineStrategy = self.combineToUnload:getCpDriveStrategy()
+ local combineStrategy = self:getCombineStrategy()
local gx, gz = self:followChopper()
if combineStrategy:isTurning() and not combineStrategy:isFinishingRow() then
@@ -792,10 +851,10 @@ function AIDriveStrategyUnloadCombine:handleChopper180Turn()
return
end
- if self.combineToUnload:getCpDriveStrategy():isTurningButNotEndingTurn() then
- if self.combineToUnload:getCpDriveStrategy():isTurnForwardOnly() then
+ if self:getCombineStrategy():isTurningButNotEndingTurn() then
+ if self:getCombineStrategy():isTurnForwardOnly() then
---@type Course
- local turnCourse = self.combineToUnload:getCpDriveStrategy():getTurnCourse()
+ local turnCourse = self:getCombineStrategy():getTurnCourse()
if turnCourse then
self:debug('Follow chopper through the turn')
self:startCourse(turnCourse:copy(self.vehicle), 1)
@@ -868,7 +927,8 @@ end
function AIDriveStrategyUnloadCombine:handleChopperTurn(harvester)
-- since we are taking care of staying away, ask the chopper to ignore us
- harvester:getCpDriveStrategy():requestToIgnoreProximity(self.vehicle)
+ local harvesterStrategy = self:getCombineStrategy()
+ harvesterStrategy:requestToIgnoreProximity(self.vehicle)
local d, dx, dz = self:getDistanceFromCombine(harvester)
local combineSpeed = harvester.lastSpeedReal * 3600
@@ -886,12 +946,12 @@ function AIDriveStrategyUnloadCombine:handleChopperTurn(harvester)
-- stay closer when still discharging
if sameDirection then
-- reverse speed is controlled around combine's speed
- dReference = harvester:getCpDriveStrategy():isDischarging() and dz or dz - 3
+ dReference = harvesterStrategy:isDischarging() and dz or dz - 3
speed = combineSpeed + CpMathUtil.clamp(self.targetDistanceBehindChopper - dReference, -combineSpeed,
self.settings.reverseSpeed:getValue() * 1.5)
else
-- reverse speed only depends on distance from the combine, stop when at working width
- speed = CpMathUtil.clamp(harvester:getCpDriveStrategy():getWorkWidth() - d, 0,
+ speed = CpMathUtil.clamp(harvesterStrategy:getWorkWidth() - d, 0,
self.settings.reverseSpeed:getValue() * 1.5)
end
else
@@ -926,8 +986,8 @@ function AIDriveStrategyUnloadCombine:followChopperThroughTurn()
end
local d = self:getDistanceFromCombine()
- local turnCourse = self.combineToUnload:getCpDriveStrategy():getTurnCourse()
- if self.combineToUnload:getCpDriveStrategy():isTurning() and turnCourse ~= nil then
+ local turnCourse = self:getCombineStrategy():getTurnCourse()
+ if self:getCombineStrategy():isTurning() and turnCourse ~= nil then
-- making sure we are never ahead of the chopper on the course (we both drive the same course), this
-- prevents the unloader cutting in front of the chopper when for example the unloader is on the
-- right side of the chopper and the chopper reaches a right turn.
@@ -951,9 +1011,13 @@ end
--- side of the chopper is already harvested, or behind it if both sides have fruit.
------------------------------------------------------------------------------------------------------------------------
function AIDriveStrategyUnloadCombine:calculateAutoAimPipeOffsetX(harvester)
- local strategy = harvester and harvester:getCpDriveStrategy()
+ local strategy = harvester and self:getCombineStrategy()
if strategy and strategy.hasAutoAimPipe and strategy:hasAutoAimPipe() then
local fruitLeft, fruitRight = strategy:getFruitAtSides()
+ -- getFruitAtSides() can return nil before checkFruit() has run (strategy just created,
+ -- or proxy returns nil, nil). Default to 0 so the arithmetic below doesn't crash.
+ fruitLeft = fruitLeft or 0
+ fruitRight = fruitRight or 0
local targetOffsetX, distanceBetweenVehicles = 0, (AIUtil.getWidth(harvester) + AIUtil.getWidth(self.vehicle)) / 2 + 1
-- we use 20% of the average as a threshold for significant difference
local fruitThreshold = 0.2 * 0.5 * (fruitLeft + fruitRight)
@@ -1080,14 +1144,14 @@ function AIDriveStrategyUnloadCombine:getPipesBaseNode(combine)
end
function AIDriveStrategyUnloadCombine:getCombineIsTurning()
- return self.combineToUnload:getCpDriveStrategy() and self.combineToUnload:getCpDriveStrategy():isTurning()
+ return self:getCombineStrategy() and self:getCombineStrategy():isTurning()
end
---@return number, number x and z offset of the pipe's end from the combine's root node in the Giants coordinate system
---(x > 0 left, z > 0 forward) corrected with the manual offset settings
function AIDriveStrategyUnloadCombine:getPipeOffset(combine)
- local offsetX, offsetZ = combine:getCpDriveStrategy():getPipeOffset(-self.settings.combineOffsetX:getValue(), self.settings.combineOffsetZ:getValue())
- if combine:getCpDriveStrategy():hasAutoAimPipe() then
+ local offsetX, offsetZ = self:getCombineStrategy():getPipeOffset(-self.settings.combineOffsetX:getValue(), self.settings.combineOffsetZ:getValue())
+ if self:getCombineStrategy():hasAutoAimPipe() then
return self:getAutoAimPipeOffsetX(), offsetZ
else
return offsetX, offsetZ
@@ -1097,7 +1161,9 @@ end
---@return number offset X for the course to follow the combine, this is the pipe offset and the combine courser offset
function AIDriveStrategyUnloadCombine:getFollowingCourseOffset(combine)
local pipeOffset = self:getPipeOffset(combine)
- local courseOffset = combine:getCpDriveStrategy():getFieldworkCourse():getOffset()
+ local combineStrategy = self:getCombineStrategy()
+ local fieldworkCourse = combineStrategy and combineStrategy:getFieldworkCourse()
+ local courseOffset = fieldworkCourse and fieldworkCourse:getOffset() or 0
return -pipeOffset + courseOffset
end
@@ -1106,7 +1172,7 @@ function AIDriveStrategyUnloadCombine:getAutoAimPipeOffsetX()
end
function AIDriveStrategyUnloadCombine:getCombinesMeasuredBackDistance()
- return self.combineToUnload:getCpDriveStrategy():getMeasuredBackDistance()
+ return self:getCombineStrategy():getMeasuredBackDistance()
end
function AIDriveStrategyUnloadCombine:getAllTrailersFull(fullThresholdPercentage)
@@ -1137,8 +1203,8 @@ end
function AIDriveStrategyUnloadCombine:releaseCombine()
self.combineJustUnloaded = nil
- if self.combineToUnload and self.combineToUnload:getIsCpActive() then
- local strategy = self.combineToUnload:getCpDriveStrategy()
+ if self.combineToUnload and self:isCombineActive() then
+ local strategy = self:getCombineStrategy()
if strategy and strategy.deregisterUnloader then
strategy:deregisterUnloader(self)
end
@@ -1232,7 +1298,7 @@ end
function AIDriveStrategyUnloadCombine:isBehindAndAlignedToCombine(debugEnabled)
-- if the harvester has an auto aim pipe, like a chopper we can relax our conditions
- local hasAutoAimPipe = self.combineToUnload:getCpDriveStrategy():hasAutoAimPipe()
+ local hasAutoAimPipe = self:getCombineStrategy():hasAutoAimPipe()
local dx, _, dz = localToLocal(self.vehicle.rootNode, self:getPipeOffsetReferenceNode(), 0, 0, 0)
local pipeOffset = self:getPipeOffset(self.combineToUnload)
if dz > (hasAutoAimPipe and -5 or 0) then
@@ -1280,11 +1346,11 @@ function AIDriveStrategyUnloadCombine:isInFrontAndAlignedToMovingCombine(debugEn
AIDriveStrategyUnloadCombine.maxDirectionDifferenceDeg)
return false
end
- if self.combineToUnload:getCpDriveStrategy():willWaitForUnloadToFinish() then
+ if self:getCombineStrategy():willWaitForUnloadToFinish() then
self:debugIf(debugEnabled, 'isInFrontAndAlignedToMovingCombine: combine is not moving')
return false
end
- if self.combineToUnload:getCpDriveStrategy():alwaysNeedsUnloader() then
+ if self:getCombineStrategy():alwaysNeedsUnloader() then
-- this harvester won't move without an unloader under the pipe, so if our fill node is in front of the
-- trailer, there is no point waiting for it
dz = self:getBestTargetNodeDistanceFromPipe()
@@ -1299,7 +1365,7 @@ function AIDriveStrategyUnloadCombine:isInFrontAndAlignedToMovingCombine(debugEn
end
function AIDriveStrategyUnloadCombine:isOkToStartUnloadingCombine()
- if self.combineToUnload:getCpDriveStrategy():isReadyToUnload(true) then
+ if self:getCombineStrategy():isReadyToUnload(true) then
-- if it always needs an unloader, it won't move without it, so can't start unloading when in front of the combine
return self:isBehindAndAlignedToCombine() or self:isInFrontAndAlignedToMovingCombine()
else
@@ -1362,7 +1428,7 @@ end
-- Start to unload the combine (driving to the pipe/closer to combine)
------------------------------------------------------------------------------------------------------------------------
function AIDriveStrategyUnloadCombine:startUnloadingCombine()
- if self.combineToUnload:getCpDriveStrategy():willWaitForUnloadToFinish() then
+ if self:getCombineStrategy():willWaitForUnloadToFinish() then
self:debug('Close enough to a stopped combine, drive to pipe')
self:startUnloadingStoppedCombine()
else
@@ -1389,7 +1455,7 @@ end
---@return number approximate waypoint index of the combine's current position
function AIDriveStrategyUnloadCombine:setupFollowCourse()
---@type Course
- self.combineCourse = self.combineToUnload:getCpDriveStrategy():getFieldworkCourse()
+ self.combineCourse = self:getCombineStrategy():getFieldworkCourse()
if not self.combineCourse then
-- TODO: handle this more gracefully, or even better, don't even allow selecting combines with no course
self:debugSparse('Waiting for combine to set up a course, can\'t follow')
@@ -1397,7 +1463,7 @@ function AIDriveStrategyUnloadCombine:setupFollowCourse()
end
local followCourse = self.combineCourse:copy(self.vehicle)
-- relevant waypoint is the closest to the combine, prefer that so our PPC will get us on course with the proper offset faster
- local followCourseIx = self.combineToUnload:getCpDriveStrategy():getClosestFieldworkWaypointIx() or self.combineCourse:getCurrentWaypointIx()
+ local followCourseIx = self:getCombineStrategy():getClosestFieldworkWaypointIx() or self.combineCourse:getCurrentWaypointIx()
return followCourse, followCourseIx
end
@@ -1412,6 +1478,32 @@ function AIDriveStrategyUnloadCombine:startCourseFollowingCombine()
self.followingCourseOffset = self:getFollowingCourseOffset(self.combineToUnload)
self.followCourse:setOffset(self.followingCourseOffset, 0)
self.reverseForTurnCourse = nil
+ -- Initialise the refresh timer so the first periodic refresh fires 5 s from now,
+ -- not on the very first update frame.
+ self.followCourseRefreshTime = g_time
+
+ -- For manual combines build a minimal placeholder course. The PPC needs SOME course to
+ -- initialise against, but we never use it for steering: driveBesideCombine() returns a
+ -- live goal point directly from the pipe reference node on every frame, bypassing the
+ -- course entirely. The placeholder is a straight 100 m course starting at the combine's
+ -- current position and pointing in the combine's current heading.
+ local combineStrategy = self:getCombineStrategy()
+ if combineStrategy and combineStrategy.isManualProxy and combineStrategy:isManualProxy() then
+ local combineX, _, combineZ = getWorldTranslation(self.combineToUnload:getAIDirectionNode())
+ local forwardX, _, forwardZ = localToWorld(self.combineToUnload:getAIDirectionNode(), 0, 0, 100)
+ local placeholder = Course.createFromTwoWorldPositions(
+ self.vehicle,
+ combineX, combineZ,
+ forwardX, forwardZ,
+ 0, 0, 0, 10, false)
+ if placeholder then
+ self.followCourse = placeholder
+ self.followCourse:setOffset(self.followingCourseOffset, 0)
+ startIx = 1
+ self:debug('Manual combine: placeholder follow course (PPC unused; direct steering via driveBesideCombine)')
+ end
+ end
+
self:debug('Will follow combine\'s course at waypoint %d, side offset %.1f', startIx, self.followCourse.offsetX)
self:startCourse(self.followCourse, startIx)
self:setNewState(self.states.UNLOADING_MOVING_COMBINE)
@@ -1422,7 +1514,7 @@ function AIDriveStrategyUnloadCombine:getCombineToUnload()
end
function AIDriveStrategyUnloadCombine:getPipeOffsetReferenceNode()
- return self.combineToUnload:getCpDriveStrategy():getPipeOffsetReferenceNode()
+ return self:getCombineStrategy():getPipeOffsetReferenceNode()
end
------------------------------------------------------------------------------------------------------------------------
@@ -1467,16 +1559,40 @@ end
------------------------------------------------------------------------------------------------------------------------
-- Pathfinding to waiting (not moving) combine
------------------------------------------------------------------------------------------------------------------------
-function AIDriveStrategyUnloadCombine:startPathfindingToWaitingCombine(xOffset, zOffset)
+--- @param failureCallback function|nil optional override for pathfinding failure; defaults to
+--- onPathfindingFailedToStationaryTarget (which stops the job). Pass onPathfindingFailedToMovingTarget
+--- for moving or manual combines so a failure only causes a wait-and-retry instead of killing the job.
+--- @param isManualCombine boolean|nil when true, tunes the pathfinder for manual-combine approach:
+--- - ignores the combine vehicle as an obstacle (prevents routing around it)
+--- - uses a relaxed fruit threshold (25% vs 10%) so the already-harvested approach strip
+--- doesn't cause excessive penalty and the search terminates much faster
+--- - caps maxIterations at the default 40 000 instead of the potentially huge
+--- field-polygon-scaled value, avoiding multi-second searches on large maps
+function AIDriveStrategyUnloadCombine:startPathfindingToWaitingCombine(xOffset, zOffset, failureCallback, isManualCombine)
local context = PathfinderContext(self.vehicle)
- local maxFruitPercent = self:getMaxFruitPercent(self:getPipeOffsetReferenceNode(), xOffset, zOffset)
+ local maxFruitPercent, vehiclesToIgnore, maxIterations
+ if isManualCombine then
+ -- The combine has just harvested the target strip so the approach path has low fruit.
+ -- A relaxed threshold finds a path in far fewer iterations and still keeps the grain
+ -- cart out of heavy standing crop.
+ maxFruitPercent = 25
+ -- Exclude the combine itself so the pathfinder routes to the gap behind it rather
+ -- than treating the combine body as a solid obstacle to go around.
+ vehiclesToIgnore = { self.combineToUnload }
+ -- Cap at the default to prevent searches that can run for tens of seconds on large fields.
+ maxIterations = HybridAStar.defaultMaxIterations
+ else
+ maxFruitPercent = self:getMaxFruitPercent(self:getPipeOffsetReferenceNode(), xOffset, zOffset)
+ vehiclesToIgnore = {}
+ maxIterations = PathfinderUtil.getMaxIterationsForFieldPolygon(self.vehicle:cpGetFieldPolygon())
+ end
context:maxFruitPercent(maxFruitPercent)
context:offFieldPenalty(self:getOffFieldPenalty(self.combineToUnload))
context:useFieldNum(CpFieldUtil.getFieldNumUnderVehicle(self.combineToUnload))
- context:areaToAvoid(self.combineToUnload:getCpDriveStrategy():getAreaToAvoid())
- context:vehiclesToIgnore({}):maxIterations(PathfinderUtil.getMaxIterationsForFieldPolygon(self.vehicle:cpGetFieldPolygon()))
+ context:areaToAvoid(self:getCombineStrategy():getAreaToAvoid())
+ context:vehiclesToIgnore(vehiclesToIgnore):maxIterations(maxIterations)
self.pathfinderController:registerListeners(self, self.onPathfindingDoneToWaitingCombine,
- self.onPathfindingFailedToStationaryTarget, self.onPathfindingObstacleAtStart)
+ failureCallback or self.onPathfindingFailedToStationaryTarget, self.onPathfindingObstacleAtStart)
self.pathfinderController:findPathToNode(context, self:getPipeOffsetReferenceNode(), xOffset or 0, zOffset or 0, 3)
end
@@ -1485,6 +1601,11 @@ function AIDriveStrategyUnloadCombine:onPathfindingDoneToWaitingCombine(controll
self:debug('Pathfinding to waiting combine successful')
course:adjustForReversing(math.max(1, -AIUtil.getDirectionNodeToReverserNodeOffset(self.vehicle)))
self:startCourse(course, 1)
+ -- Initialise the approach redirect timer and clear the last target so the first
+ -- redirect check always compares against the current pipe position.
+ self.lastApproachRedirectTime = g_time
+ self.lastApproachRedirectX = nil
+ self.lastApproachRedirectZ = nil
self:setNewState(self.states.DRIVING_TO_COMBINE)
return true
else
@@ -1624,12 +1745,12 @@ end
--- unloader to come to the combine.
---@return boolean true if the unloader has accepted the request
function AIDriveStrategyUnloadCombine:call(combine, waypoint)
+ self.combineToUnload = combine
local xOffset, zOffset = self:getPipeOffset(combine)
if waypoint then
-- combine set up a rendezvous waypoint for us, go there
if self:isPathfindingNeeded(self.vehicle, waypoint, xOffset, zOffset, 25) then
self.rendezvousWaypoint = waypoint
- self.combineToUnload = combine
-- just in case, as the combine may give us a rendezvous waypoint
-- where it is full, make sure we are behind the combine
zOffset = -self:getCombinesMeasuredBackDistance() - 5
@@ -1646,13 +1767,13 @@ function AIDriveStrategyUnloadCombine:call(combine, waypoint)
-- combine wants us to drive directly to it
self:debug('call: Combine is waiting for unload, start finding path to combine')
self.combineToUnload = combine
- if self.combineToUnload:getCpDriveStrategy():isWaitingForUnloadAfterPulledBack() then
+ if self:getCombineStrategy():isWaitingForUnloadAfterPulledBack() then
-- combine pulled back so it's pipe is now out of the fruit. In this case, if the unloader is in front
-- of the combine, it sometimes finds a path between the combine and the fruit to the pipe, we are trying to
-- fix it here: the target is behind the combine, not under the pipe. When we get there, we may need another
-- (short) pathfinding to get under the pipe.
zOffset = -self:getCombinesMeasuredBackDistance() - 10
- elseif self.combineToUnload:getCpDriveStrategy():hasAutoAimPipe() then
+ elseif self:getCombineStrategy():hasAutoAimPipe() then
if math.abs(self:getAutoAimPipeOffsetX()) < 3 then
-- will drive behind the harvester, so target must be further back, making sure there's a few meters
-- between the harvester's back and the tractor's front
@@ -1672,7 +1793,12 @@ function AIDriveStrategyUnloadCombine:call(combine, waypoint)
self:startUnloadingCombine()
elseif self:isPathfindingNeeded(self.vehicle, self:getPipeOffsetReferenceNode(), xOffset, zOffset) then
self:setNewState(self.states.WAITING_FOR_PATHFINDER)
- self:startPathfindingToWaitingCombine(xOffset, zOffset)
+ -- For manually-driven combines, tune pathfinding for faster, safer approach.
+ local isManualCombine = self.combineToUnload.cpIsManualCombineCallingUnloader and
+ self.combineToUnload:cpIsManualCombineCallingUnloader()
+ self:startPathfindingToWaitingCombine(xOffset, zOffset,
+ isManualCombine and self.onPathfindingFailedToMovingTarget or nil,
+ isManualCombine)
else
self:debug('Can\'t start pathfinding to waiting combine, and not in a good position to unload, too close?')
self:startWaitingForSomethingToDo()
@@ -1722,12 +1848,13 @@ end
function AIDriveStrategyUnloadCombine:getOffFieldPenalty(combineToUnload)
local offFieldPenalty = PathfinderContext.defaultOffFieldPenalty
if combineToUnload then
- if combineToUnload:getCpDriveStrategy():isOnHeadland(1) then
+ local strategy = self:getCombineStrategy()
+ if strategy and strategy:isOnHeadland(1) then
-- when the combine is on the first headland, chances are that we have to drive off-field to it,
-- so make the life easier for the pathfinder
offFieldPenalty = PathfinderContext.defaultOffFieldPenalty / 5
self:debug('Combine is on first headland, reducing off-field penalty for pathfinder to %.1f', offFieldPenalty)
- elseif combineToUnload:getCpDriveStrategy():isOnHeadland(2) then
+ elseif strategy and strategy:isOnHeadland(2) then
-- reduce less when on the second headland, there's more chance we'll be able to get to the combine
-- on the headland
offFieldPenalty = PathfinderContext.defaultOffFieldPenalty / 3
@@ -1796,7 +1923,7 @@ function AIDriveStrategyUnloadCombine:updateCombineStatus()
end
-- add hysteresis to reversing info from combine, isReversing() may temporarily return false during reversing, make sure we need
-- multiple update loops to change direction
- local combineToUnloadReversing = self.combineToUnloadReversing + (self.combineToUnload:getCpDriveStrategy():isReversing() and 0.1 or -0.1)
+ local combineToUnloadReversing = self.combineToUnloadReversing + (self:getCombineStrategy():isReversing() and 0.1 or -0.1)
if self.combineToUnloadReversing < 0 and combineToUnloadReversing >= 0 then
-- direction changed
self.combineToUnloadReversing = 1
@@ -1824,10 +1951,10 @@ function AIDriveStrategyUnloadCombine:changeToUnloadWhenTrailerFull()
else
self:debug('trailer full, changing to unload course.')
end
- if self.combineToUnload:getCpDriveStrategy():isTurning() or
- self.combineToUnload:getCpDriveStrategy():isAboutToTurn() then
+ if self:getCombineStrategy():isTurning() or
+ self:getCombineStrategy():isAboutToTurn() then
self:debug('... but we are too close to the end of the row, or combine is turning, moving back before changing to unload course')
- elseif self.combineToUnload and self.combineToUnload:getCpDriveStrategy():isAboutToReturnFromPocket() then
+ elseif self.combineToUnload and self:getCombineStrategy():isAboutToReturnFromPocket() then
self:debug('... letting the combine return from the pocket')
else
self:debug('... moving back a little in case AD wants to take over')
@@ -1858,7 +1985,7 @@ end
--- we probably rather not approach the area around the turn so we are not in the way
--- of the combine while it is turning.
function AIDriveStrategyUnloadCombine:checkForCombineTurnArea()
- local turnAreaCenterWp, r = self.combineToUnload:getCpDriveStrategy():getTurnArea()
+ local turnAreaCenterWp, r = self:getCombineStrategy():getTurnArea()
if turnAreaCenterWp and turnAreaCenterWp:getDistanceFromVehicle(self.vehicle) <= r then
self:debugSparse('Waiting for combine to pass the turn at %.1f, %.1f (r = %.1f) before the rendezvous waypoint',
turnAreaCenterWp.x, turnAreaCenterWp.z, r)
@@ -1875,7 +2002,48 @@ function AIDriveStrategyUnloadCombine:driveToCombine()
self:setFieldSpeed()
- self.combineToUnload:getCpDriveStrategy():reconfirmRendezvous()
+ self:getCombineStrategy():reconfirmRendezvous()
+
+ -- Every ~10 s, redirect toward the combine's live position without stopping, but ONLY if the
+ -- combine's pipe has moved more than 15 m from where we last aimed. This prevents jarring
+ -- course corrections when the combine is driving straight and the redirect is unnecessary.
+ if g_time - (self.lastApproachRedirectTime or g_time) > 10000 then
+ self.lastApproachRedirectTime = g_time
+ local d = self:getDistanceFromCombine()
+ -- Only redirect when the combine is far enough that a course update is meaningful;
+ -- close-in positioning is handled by isOkToStartUnloadingCombine() below.
+ if d > 20 then
+ -- Check whether the pipe has actually moved enough to warrant a new course.
+ local cX, cY, cZ = getWorldTranslation(self:getPipeOffsetReferenceNode())
+ local lastX = self.lastApproachRedirectX or cX
+ local lastZ = self.lastApproachRedirectZ or cZ
+ local pipeMoved = MathUtil.vector2Length(cX - lastX, cZ - lastZ)
+ -- Straight-line redirect is only safe when the combine is roughly ahead of the grain
+ -- cart (within ~40°). If the combine is off to the side (e.g. it turned onto the next
+ -- row) a straight line would cut through standing crop. Skip the redirect in that case
+ -- and let the next A* call handle the reroute.
+ local lx, _, lz = worldToLocal(self.vehicle:getAIDirectionNode(), cX, cY, cZ)
+ local combineAhead = lz > 0
+ local angleToCombieDeg = math.deg(math.abs(math.atan2(lx, math.max(lz, 0.001))))
+ if combineAhead and angleToCombieDeg < 40 and pipeMoved > 15 then
+ self.lastApproachRedirectX = cX
+ self.lastApproachRedirectZ = cZ
+ local xOffset, zOffset = self:getPipeOffset(self.combineToUnload)
+ local zTarget = -self:getCombinesMeasuredBackDistance() - 3
+ local redirectCourse = Course.createFromNodeToNode(
+ self.vehicle,
+ self.vehicle:getAIDirectionNode(),
+ self:getPipeOffsetReferenceNode(),
+ xOffset, 0, zTarget, 5, false)
+ if redirectCourse then
+ self:debug('driveToCombine: redirecting toward combine live position (d=%.1f m, angle=%.1f°, pipeMoved=%.1f m)', d, angleToCombieDeg, pipeMoved)
+ self:startCourse(redirectCourse, 1)
+ end
+ else
+ self:debug('driveToCombine: skipping redirect, combine is off-axis (ahead=%s, angle=%.1f°) or pipe has not moved enough (%.1f m)', tostring(combineAhead), angleToCombieDeg, pipeMoved)
+ end
+ end
+ end
-- towards the end of the course we start checking if we can already switch to unload
if self.course:getDistanceToLastWaypoint(self.course:getCurrentWaypointIx()) < 15 and
@@ -1896,26 +2064,26 @@ function AIDriveStrategyUnloadCombine:driveToMovingCombine()
self:checkForCombineTurnArea()
-- stop when too close to a combine not ready to unload (wait until it is done with turning for example)
- if self:isWithinSafeManeuveringDistance(self.combineToUnload) and self.combineToUnload:getCpDriveStrategy():isManeuvering() then
+ if self:isWithinSafeManeuveringDistance(self.combineToUnload) and self:getCombineStrategy():isManeuvering() then
self:startWaitingForManeuveringCombine()
elseif self:isOkToStartUnloadingCombine() then
self:startUnloadingCombine()
end
- if self.combineToUnload:getCpDriveStrategy():isWaitingForUnload() then
+ if self:getCombineStrategy():isWaitingForUnload() then
self:debug('combine is now stopped and waiting for unload, wait for it to call again')
self:startWaitingForSomethingToDo()
return
end
if self.course:isCloseToLastWaypoint(AIDriveStrategyUnloadCombine.driveToCombineCourseExtensionLength / 2) and
- self.combineToUnload:getCpDriveStrategy():hasRendezvousWith(self.vehicle) then
+ self:getCombineStrategy():hasRendezvousWith(self.vehicle) then
self:debugSparse('Combine is late, waiting ...')
self:setMaxSpeed(0)
-- stop confirming the rendezvous, allow the combine to time out if it can't get here on time
else
-- yes honey, I'm on my way!
- self.combineToUnload:getCpDriveStrategy():reconfirmRendezvous()
+ self:getCombineStrategy():reconfirmRendezvous()
end
end
@@ -1933,7 +2101,7 @@ function AIDriveStrategyUnloadCombine:startWaitingForManeuveringCombine()
end
function AIDriveStrategyUnloadCombine:waitForManeuveringCombine()
- if self:isWithinSafeManeuveringDistance(self.combineToUnload) and self.combineToUnload:getCpDriveStrategy():isManeuvering() then
+ if self:isWithinSafeManeuveringDistance(self.combineToUnload) and self:getCombineStrategy():isManeuvering() then
self:setMaxSpeed(0)
else
self:debug('Combine stopped maneuvering')
@@ -1959,7 +2127,7 @@ function AIDriveStrategyUnloadCombine:unloadStoppedCombine()
return
end
local gx, gz
- local combineDriver = self.combineToUnload:getCpDriveStrategy()
+ local combineDriver = self:getCombineStrategy()
if combineDriver:isUnloadFinished() then
if combineDriver:isWaitingForUnloadAfterCourseEnded() then
if combineDriver:getFillLevelPercentage() < 0.1 then
@@ -2002,9 +2170,22 @@ function AIDriveStrategyUnloadCombine:unloadMovingCombine()
return
end
- local combineStrategy = self.combineToUnload:getCpDriveStrategy()
+ local combineStrategy = self:getCombineStrategy()
local gx, gz = self:driveBesideCombine()
+ -- For manually-driven combines the strategy IS a CpManualCombineProxy.
+ -- The farmer is in full control: ignore fill level, alignment, turning state, etc.
+ -- Stay under the pipe until the proxy's isUnloadFinished() fires (pipe closed for 2s)
+ -- or the grain cart's own trailer fills up (handled by changeToUnloadWhenTrailerFull above).
+ -- The cart will drift from the static placeholder course as the combine curves — disable the
+ -- PPC off-track check for the duration (5000 ms >> any realistic frame interval).
+ if combineStrategy.isManualProxy then
+ self.ppc:disableStopWhenOffTrack(5000)
+ self:debugSparse('unloadMovingCombine (manual): isDischarging=%s',
+ tostring(combineStrategy:isDischarging()))
+ return gx, gz
+ end
+
--when the combine is empty, stop and wait for next combine (unless this can't work without an unloader nearby)
if combineStrategy:getFillLevelPercentage() <= 0.1 and not combineStrategy:alwaysNeedsUnloader() then
self:debug('Combine empty, finish unloading.')
@@ -2061,7 +2242,6 @@ function AIDriveStrategyUnloadCombine:unloadMovingCombine()
self:isBehindAndAlignedToCombine(true)
self:isInFrontAndAlignedToMovingCombine(true)
self:info('not in a good position to unload, cancelling rendezvous, trying to recover')
- -- for some reason (like combine turned) we are not in a good position anymore then set us up again
self:startWaitingForSomethingToDo()
end
return gx, gz
@@ -2170,12 +2350,15 @@ function AIDriveStrategyUnloadCombine:onBlockingVehicle(blockingVehicle, isBack)
-- TODO: maybe a generic getTrailer() ?
local referenceObject = AIUtil.getImplementOrVehicleWithSpecialization(self.vehicle, Trailer) or
AIUtil.getImplementOrVehicleWithSpecialization(self.vehicle, HookLiftTrailer) or self.vehicle
- if AIDriveStrategyCombineCourse.isActiveCpCombine(blockingVehicle) then
+ local isManualBlocker = blockingVehicle.cpIsManualCombineCallingUnloader and blockingVehicle:cpIsManualCombineCallingUnloader()
+ if AIDriveStrategyCombineCourse.isActiveCpCombine(blockingVehicle) or isManualBlocker then
-- except we are blocking our buddy, so set up a course parallel to the combine's direction,
-- with an offset from the combine that makes sure we are clear. Use the trailer's root node (and not
-- the tractor's) as when we reversing, it is easier when the trailer remains on the same side of the combine
local dx, _, _ = localToLocal(referenceObject.rootNode, blockingVehicle:getAIDirectionNode(), 0, 0, 0)
- local xOffset = self.vehicle.size.width / 2 + blockingVehicle:getCpDriveStrategy():getWorkWidth() / 2 + 2
+ local blockingStrategy = blockingVehicle:getCpDriveStrategy() or (blockingVehicle.cpGetManualCombineProxy and blockingVehicle:cpGetManualCombineProxy())
+ local blockingWorkWidth = blockingStrategy:getWorkWidth()
+ local xOffset = self.vehicle.size.width / 2 + blockingWorkWidth / 2 + 2
xOffset = dx > 0 and xOffset or -xOffset
self:setNewState(self.states.MOVING_AWAY_FROM_OTHER_VEHICLE)
self.state.properties.vehicle = blockingVehicle
@@ -2368,7 +2551,7 @@ end
function AIDriveStrategyUnloadCombine:makeRoomForCombineTurningOnHeadland()
local dProximity, _ = self.proximityController:checkBlockingVehicleFront()
local d, _, dz = self:getDistanceFromCombine(self.combineToUnload)
- local dLimit = 0.6 * self.combineToUnload:getCpDriveStrategy():getWorkWidth()
+ local dLimit = 0.6 * self:getCombineStrategy():getWorkWidth()
-- if we are already behind the harvester's back and far enough and not blocking it and
-- not in our proximity, then stop
if dz > 0 and d > dLimit and dProximity > dLimit then
diff --git a/scripts/events/CpManualUnloaderEvent.lua b/scripts/events/CpManualUnloaderEvent.lua
new file mode 100644
index 000000000..8222d43ca
--- /dev/null
+++ b/scripts/events/CpManualUnloaderEvent.lua
@@ -0,0 +1,42 @@
+---@class CpManualUnloaderEvent
+CpManualUnloaderEvent = {}
+local CpManualUnloaderEvent_mt = Class(CpManualUnloaderEvent, Event)
+
+InitEventClass(CpManualUnloaderEvent, "CpManualUnloaderEvent")
+
+function CpManualUnloaderEvent.emptyNew()
+ local self = Event.new(CpManualUnloaderEvent_mt)
+ return self
+end
+
+function CpManualUnloaderEvent.new(vehicle)
+ local self = CpManualUnloaderEvent.emptyNew()
+ self.vehicle = vehicle
+ return self
+end
+
+function CpManualUnloaderEvent:readStream(streamId, connection)
+ self.vehicle = NetworkUtil.readNodeObject(streamId)
+ self:run(connection)
+end
+
+function CpManualUnloaderEvent:writeStream(streamId, connection)
+ NetworkUtil.writeNodeObject(streamId, self.vehicle)
+end
+
+function CpManualUnloaderEvent:run(connection)
+ if self.vehicle and self.vehicle.cpToggleManualUnloader then
+ self.vehicle:cpToggleManualUnloader()
+ end
+ if not connection:getIsServer() then
+ g_server:broadcastEvent(CpManualUnloaderEvent.new(self.vehicle), nil, connection, self.vehicle)
+ end
+end
+
+function CpManualUnloaderEvent.sendEvent(vehicle)
+ if g_server ~= nil then
+ g_server:broadcastEvent(CpManualUnloaderEvent.new(vehicle), nil, nil, vehicle)
+ else
+ g_client:getServerConnection():sendEvent(CpManualUnloaderEvent.new(vehicle))
+ end
+end
diff --git a/scripts/gui/hud/CpFieldworkHudPage.lua b/scripts/gui/hud/CpFieldworkHudPage.lua
index 21f27c862..2880d7aa7 100644
--- a/scripts/gui/hud/CpFieldworkHudPage.lua
+++ b/scripts/gui/hud/CpFieldworkHudPage.lua
@@ -78,7 +78,23 @@ function CpFieldWorkHudPageElement:setupElements(baseHud, vehicle, lines, wMargi
baseHud:openCourseManagerGui(vehicle)
end, vehicle)
- CpGuiUtil.addCopyCourseBtn(self, baseHud, vehicle, lines, wMargin, hMargin, 1)
+ CpGuiUtil.addCopyCourseBtn(self, baseHud, vehicle, lines, wMargin, hMargin, 1)
+
+ --- Call Unloader toggle button (left side)
+ self.callManualUnloaderBtn = baseHud:addLeftLineTextButton(self, 6, CpBaseHud.defaultFontSize,
+ function(vehicle)
+ if vehicle.cpToggleManualUnloader then
+ vehicle:cpToggleManualUnloader()
+ end
+ end, vehicle)
+
+ --- Call Unloader status text (right side)
+ self.callManualUnloaderStatus = baseHud:addRightLineTextButton(self, 6, CpBaseHud.defaultFontSize,
+ function(vehicle)
+ if vehicle.cpToggleManualUnloader then
+ vehicle:cpToggleManualUnloader()
+ end
+ end, vehicle)
end
function CpFieldWorkHudPageElement:update(dt)
@@ -130,4 +146,26 @@ function CpFieldWorkHudPageElement:updateContent(vehicle, status)
end
CpGuiUtil.updateCopyBtn(self, vehicle, status)
+
+ if self.callManualUnloaderBtn then
+ local hasPipe = vehicle.spec_pipe ~= nil or AIUtil.hasChildVehicleWithSpecialization(vehicle, Pipe)
+ local isCpActive = vehicle:getIsCpActive()
+ local isCallActive = vehicle.cpIsManualCombineCallingUnloader and vehicle:cpIsManualCombineCallingUnloader()
+ -- Forage harvesters have a rotatable auto-aim spout and are not supported — hide the button entirely.
+ local showBtn = hasPipe and not isCpActive and not ImplementUtil.isChopper(vehicle)
+ self.callManualUnloaderBtn:setVisible(showBtn)
+ self.callManualUnloaderStatus:setVisible(showBtn)
+ if showBtn then
+ self.callManualUnloaderBtn:setTextDetails(g_i18n:getText("CP_callManualUnloader"))
+ if isCallActive then
+ self.callManualUnloaderBtn:setColor(unpack(CpBaseHud.ON_COLOR))
+ self.callManualUnloaderStatus:setTextDetails(g_i18n:getText("CP_callManualUnloaderActive"))
+ self.callManualUnloaderStatus:setColor(unpack(CpBaseHud.ON_COLOR))
+ else
+ self.callManualUnloaderBtn:setColor(unpack(CpBaseHud.OFF_COLOR))
+ self.callManualUnloaderStatus:setTextDetails(g_i18n:getText("CP_callManualUnloaderInactive"))
+ self.callManualUnloaderStatus:setColor(unpack(CpBaseHud.OFF_COLOR))
+ end
+ end
+ end
end
diff --git a/scripts/specializations/CpAIFieldWorker.lua b/scripts/specializations/CpAIFieldWorker.lua
index 0206324c7..d7b7625ab 100644
--- a/scripts/specializations/CpAIFieldWorker.lua
+++ b/scripts/specializations/CpAIFieldWorker.lua
@@ -41,6 +41,8 @@ end
function CpAIFieldWorker.registerEventListeners(vehicleType)
SpecializationUtil.registerEventListener(vehicleType, "onLoad", CpAIFieldWorker)
SpecializationUtil.registerEventListener(vehicleType, "onLoadFinished", CpAIFieldWorker)
+ SpecializationUtil.registerEventListener(vehicleType, "onUpdate", CpAIFieldWorker)
+ SpecializationUtil.registerEventListener(vehicleType, "onDelete", CpAIFieldWorker)
SpecializationUtil.registerEventListener(vehicleType, "onCpEmpty", CpAIFieldWorker)
SpecializationUtil.registerEventListener(vehicleType, "onCpFull", CpAIFieldWorker)
@@ -70,6 +72,10 @@ function CpAIFieldWorker.registerFunctions(vehicleType)
SpecializationUtil.registerFunction(vehicleType, "startCpAtLastWp", CpAIFieldWorker.startCpAtLastWp)
SpecializationUtil.registerFunction(vehicleType, "getCpStartingPointSetting", CpAIFieldWorker.getCpStartingPointSetting)
SpecializationUtil.registerFunction(vehicleType, "getCpLaneOffsetSetting", CpAIFieldWorker.getCpLaneOffsetSetting)
+
+ SpecializationUtil.registerFunction(vehicleType, "cpToggleManualUnloader", CpAIFieldWorker.cpToggleManualUnloader)
+ SpecializationUtil.registerFunction(vehicleType, "cpIsManualCombineCallingUnloader", CpAIFieldWorker.cpIsManualCombineCallingUnloader)
+ SpecializationUtil.registerFunction(vehicleType, "cpGetManualCombineProxy", CpAIFieldWorker.cpGetManualCombineProxy)
end
function CpAIFieldWorker.registerOverwrittenFunctions(vehicleType)
@@ -260,6 +266,63 @@ function CpAIFieldWorker:onCpFinished()
end
+------------------------------------------------------------------------------------------------------------------------
+--- Manual combine "Call Unloader" proxy management
+------------------------------------------------------------------------------------------------------------------------
+
+function CpAIFieldWorker:onUpdate(dt)
+ local spec = CpAIFieldWorker.getSpec(self)
+ if spec and spec.cpManualCombineProxy then
+ -- If the player handed the combine over to CP (e.g. activated autopilot mid-session),
+ -- deactivate the manual unloader proxy so it doesn't conflict with the CP-driven combine.
+ if self:getIsCpActive() then
+ self:cpToggleManualUnloader()
+ else
+ spec.cpManualCombineProxy:update(dt)
+ end
+ end
+end
+
+function CpAIFieldWorker:onDelete()
+ local spec = CpAIFieldWorker.getSpec(self)
+ if spec and spec.cpManualCombineProxy then
+ spec.cpManualCombineProxy:delete()
+ spec.cpManualCombineProxy = nil
+ end
+end
+
+function CpAIFieldWorker:cpToggleManualUnloader()
+ local spec = CpAIFieldWorker.getSpec(self)
+ if not spec then return end
+ if spec.cpManualCombineProxy then
+ CpUtil.debugVehicle(CpDebug.DBG_FIELDWORK, self, 'Call grain cart deactivated')
+ spec.cpManualCombineProxy:delete()
+ spec.cpManualCombineProxy = nil
+ else
+ if self:getIsCpActive() then
+ CpUtil.debugVehicle(CpDebug.DBG_FIELDWORK, self, 'Cannot call grain cart while CP is active')
+ return
+ end
+ CpUtil.debugVehicle(CpDebug.DBG_FIELDWORK, self, 'Call grain cart activated')
+ spec.cpManualCombineProxy = CpManualCombineProxy(self)
+ end
+ if not self.isServer then
+ CpManualUnloaderEvent.sendEvent(self)
+ end
+end
+
+function CpAIFieldWorker:cpIsManualCombineCallingUnloader()
+ local spec = CpAIFieldWorker.getSpec(self)
+ return spec and spec.cpManualCombineProxy ~= nil
+end
+
+function CpAIFieldWorker:cpGetManualCombineProxy()
+ local spec = CpAIFieldWorker.getSpec(self)
+ return spec and spec.cpManualCombineProxy
+end
+
+------------------------------------------------------------------------------------------------------------------------
+
function CpAIFieldWorker:getCanStartCpFieldWork()
self:updateAIFieldWorkerImplementData()
-- built in helper can't handle it, but we may be able to ...
diff --git a/scripts/specializations/CpAIWorker.lua b/scripts/specializations/CpAIWorker.lua
index 75655dc28..c2ad86271 100644
--- a/scripts/specializations/CpAIWorker.lua
+++ b/scripts/specializations/CpAIWorker.lua
@@ -180,6 +180,12 @@ function CpAIWorker:onRegisterActionEvents(isActiveForInput, isActiveForInputIgn
CpGuiUtil.openCourseManagerGui(self)
end, g_i18n:getText("input_CP_OPEN_COURSEMANAGER"))
+ addActionEvent(self, InputAction.CP_CALL_GRAIN_CART, function ()
+ if self.cpToggleManualUnloader then
+ self:cpToggleManualUnloader()
+ end
+ end)
+
CpAIWorker.updateActionEvents(self)
end
end
@@ -245,6 +251,21 @@ function CpAIWorker:updateActionEvents()
actionEvent = spec.actionEvents[InputAction.CP_GENERATE_COURSE]
g_inputBinding:setActionEventActive(actionEvent.actionEventId, self:getCanStartCpFieldWork())
+
+ actionEvent = spec.actionEvents[InputAction.CP_CALL_GRAIN_CART]
+ if actionEvent then
+ local hasPipe = self.spec_pipe ~= nil or AIUtil.hasChildVehicleWithSpecialization(self, Pipe)
+ local isCpActive = self:getIsCpActive()
+ -- Forage harvesters (auto-aim rotatable spout) are not supported — hide the keybind.
+ local showCallManualUnloader = hasPipe and not isCpActive and not ImplementUtil.isChopper(self)
+ g_inputBinding:setActionEventActive(actionEvent.actionEventId, showCallManualUnloader)
+ if showCallManualUnloader then
+ local isActive = self.cpIsManualCombineCallingUnloader and self:cpIsManualCombineCallingUnloader()
+ local status = isActive and g_i18n:getText("CP_callManualUnloaderActive") or g_i18n:getText("CP_callManualUnloaderInactive")
+ g_inputBinding:setActionEventText(actionEvent.actionEventId,
+ string.format("%s (%s)", g_i18n:getText("CP_callManualUnloader"), status))
+ end
+ end
end
end
diff --git a/translations/translation_en.xml b/translations/translation_en.xml
index 29744c145..f26f40018 100644
--- a/translations/translation_en.xml
+++ b/translations/translation_en.xml
@@ -13,6 +13,9 @@
+
+
+
@@ -1103,5 +1106,6 @@ Now your selection should look similar to the image.
+