diff --git a/modDesc.xml b/modDesc.xml index 8bebc44..7052167 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -217,6 +217,7 @@ Version 2.0.26.0 : + diff --git a/scripts/HP_AutoDriveContinuity.lua b/scripts/HP_AutoDriveContinuity.lua new file mode 100644 index 0000000..961f0e8 --- /dev/null +++ b/scripts/HP_AutoDriveContinuity.lua @@ -0,0 +1,387 @@ +-- HP_AutoDriveContinuity.lua (FS25_HelperProfiles) +-- AutoDrive helper continuity bridge. +-- +-- V5 uses HelperProfiles' proven worker-appearance assignment hook as the +-- authoritative vehicle<->helper relationship. HP_WorkerAppearance already sees +-- Enterable.setRandomVehicleCharacter(vehicle, helper) in the live game and stores +-- that exact pair in vehicleAssignments. We retain that assignment across +-- AutoDrive's internal release/reacquire cycle without depending on AutoDrive's +-- private event globals. + +print("[FS25_HelperProfiles/AutoDriveV5] Source loaded (worker-assignment continuity build)") + +-- Disable the original polling prototype in HP_Compatibility.lua. This module owns +-- AutoDrive continuity on this branch. +if HP_AutoDriveContinuity ~= nil then + HP_AutoDriveContinuity.update = function() end +end + +HP_AutoDriveContinuityV5 = HP_AutoDriveContinuityV5 or { + installed = false, + reservations = setmetatable({}, {__mode = "k"}), + pendingByVehicle = setmetatable({}, {__mode = "k"}), + originalGetRandomHelper = nil, + originalReleaseHelper = nil, + originalIsHelperActive = nil, + runtimeManager = nil, + _lastWaitReason = nil, + _lastWaitLogMs = -100000 +} + +local LOG = "[FS25_HelperProfiles/AutoDriveV5] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function nowMs() + return tonumber(g_time) or 0 +end + +local function vehicleName(vehicle) + if vehicle ~= nil and type(vehicle.getFullName) == "function" then + local ok, value = pcall(vehicle.getFullName, vehicle) + if ok and value ~= nil and tostring(value) ~= "" then + return tostring(value) + end + end + if vehicle ~= nil and type(vehicle.getName) == "function" then + local ok, value = pcall(vehicle.getName, vehicle) + if ok and value ~= nil and tostring(value) ~= "" then + return tostring(value) + end + end + return tostring(vehicle or "unknown-vehicle") +end + +local function helperName(helper) + return tostring(helper ~= nil and helper.name or "?") +end + +local function isAutoDriveVehicle(vehicle) + return vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.stateModule ~= nil +end + +local function isAutoDriveActive(vehicle) + if not isAutoDriveVehicle(vehicle) then + return false + end + + local stateModule = vehicle.ad.stateModule + if type(stateModule.isActive) ~= "function" then + return false + end + + local ok, value = pcall(stateModule.isActive, stateModule) + return ok and value == true +end + +local function helperIsFree(helper) + if helper == nil then + return false + end + + -- releaseHelper() has already completed before AutoDrive synchronously calls + -- getRandomHelper() again. Do not require membership in availableHelpers here: + -- HelperProfiles/roster filtering may proxy that table, while helper.inUse is + -- the direct ownership state we need for this tiny transition window. + return helper.inUse ~= true +end + +function HP_AutoDriveContinuityV5:_logInstallWait(reason) + local now = nowMs() + reason = tostring(reason or "unknown") + if self._lastWaitReason ~= reason or (now - (tonumber(self._lastWaitLogMs) or 0)) >= 10000 then + self._lastWaitReason = reason + self._lastWaitLogMs = now + log("Waiting to install: %s", reason) + end +end + +function HP_AutoDriveContinuityV5:_reserve(vehicle, helper, reason) + if vehicle == nil or helper == nil then + return false + end + + local previous = self.reservations[vehicle] + if previous ~= nil and previous.helper == helper then + previous.helperIndex = tonumber(helper.index) or previous.helperIndex or 0 + return true + end + + self.reservations[vehicle] = { + helper = helper, + helperIndex = tonumber(helper.index) or 0, + observedAt = nowMs() + } + + log( + "Driver session reserved: vehicle='%s' helper='%s' index=%d reason=%s", + vehicleName(vehicle), + helperName(helper), + tonumber(helper.index) or 0, + tostring(reason or "unknown") + ) + return true +end + +function HP_AutoDriveContinuityV5:_clear(vehicle, reason) + local reservation = vehicle ~= nil and self.reservations[vehicle] or nil + if reservation == nil then + self.pendingByVehicle[vehicle] = nil + return false + end + + log( + "Driver session cleared: vehicle='%s' helper='%s' reason=%s", + vehicleName(vehicle), + helperName(reservation.helper), + tostring(reason or "unknown") + ) + + self.reservations[vehicle] = nil + self.pendingByVehicle[vehicle] = nil + return true +end + +function HP_AutoDriveContinuityV5:isReserved(helper) + if helper == nil then + return false + end + + for _, reservation in pairs(self.reservations or {}) do + if reservation ~= nil and reservation.helper == helper then + return true + end + end + return false +end + +function HP_AutoDriveContinuityV5:_syncWorkerAssignments() + if HP_WorkerAppearance == nil or type(HP_WorkerAppearance.vehicleAssignments) ~= "table" then + return + end + + for vehicle, assignment in pairs(HP_WorkerAppearance.vehicleAssignments) do + local helper = assignment ~= nil and assignment.helper or nil + if vehicle ~= nil and helper ~= nil and isAutoDriveVehicle(vehicle) and isAutoDriveActive(vehicle) then + local existing = self.reservations[vehicle] + local pending = self.pendingByVehicle[vehicle] + + -- During a continuity transition, never let a later appearance update + -- replace the reserved owner before getRandomHelper has had a chance to + -- return that owner. In the normal path this branch is never needed, + -- because getRandomHelper is intercepted first. + if existing ~= nil and pending ~= nil and existing.helper ~= helper then + log( + "Ignoring replacement assignment while continuity is pending: vehicle='%s' reserved='%s' observed='%s'", + vehicleName(vehicle), + helperName(existing.helper), + helperName(helper) + ) + else + self:_reserve(vehicle, helper, "worker-appearance-assignment") + end + end + end +end + +function HP_AutoDriveContinuityV5:_findReservedVehicleForHelper(helper) + if helper == nil then + return nil + end + + local match = nil + local matches = 0 + for vehicle, reservation in pairs(self.reservations or {}) do + if reservation ~= nil and reservation.helper == helper then + match = vehicle + matches = matches + 1 + end + end + + if matches == 1 then + return match + end + if matches > 1 then + log("Release mapping ambiguous: helper='%s' has %d reserved AutoDrive vehicles", helperName(helper), matches) + end + return nil +end + +function HP_AutoDriveContinuityV5:_observeRelease(helper) + local vehicle = self:_findReservedVehicleForHelper(helper) + if vehicle == nil then + return false + end + + self.pendingByVehicle[vehicle] = { + helper = helper, + releasedAt = nowMs() + } + + log( + "Driver release captured: vehicle='%s' helper='%s' adActive=%s; retaining reservation for synchronous restart", + vehicleName(vehicle), + helperName(helper), + tostring(isAutoDriveActive(vehicle)) + ) + return true +end + +function HP_AutoDriveContinuityV5:_getPendingReacquire() + local matchedVehicle = nil + local matchedHelper = nil + local matches = 0 + + for vehicle, pending in pairs(self.pendingByVehicle or {}) do + local reservation = self.reservations[vehicle] + local helper = pending ~= nil and pending.helper or nil + + if reservation ~= nil and helper ~= nil and reservation.helper == helper and isAutoDriveActive(vehicle) then + if helperIsFree(helper) then + matchedVehicle = vehicle + matchedHelper = helper + matches = matches + 1 + else + log( + "Pending restart found but helper still in use: vehicle='%s' helper='%s'", + vehicleName(vehicle), + helperName(helper) + ) + end + end + end + + if matches == 1 then + self.pendingByVehicle[matchedVehicle] = nil + log( + "Driver continuity reacquire: vehicle='%s' helper='%s' index=%d reason=release-restart", + vehicleName(matchedVehicle), + helperName(matchedHelper), + tonumber(matchedHelper.index) or 0 + ) + return matchedHelper, matchedVehicle + end + + if matches > 1 then + log("Continuity skipped: %d released AutoDrive vehicles are simultaneously requesting helpers", matches) + end + return nil, nil +end + +function HP_AutoDriveContinuityV5:_expireStoppedPending() + for vehicle, pending in pairs(self.pendingByVehicle or {}) do + if pending ~= nil and not isAutoDriveActive(vehicle) then + -- AutoDrive's internal RestartADTask restarts synchronously. If we have + -- reached a later update frame and the vehicle is still inactive, this + -- was a genuine stop rather than the temporary release/reacquire cycle. + self:_clear(vehicle, "autodrive-stopped-no-synchronous-restart") + end + end +end + +function HP_AutoDriveContinuityV5:install() + if self.installed then + return true + end + + if HelperProfiles == nil then + self:_logInstallWait("HelperProfiles global unavailable") + return false + end + if HelperProfiles._hooksDone ~= true then + self:_logInstallWait("HelperProfiles getRandomHelper hook not ready") + return false + end + if HP_WorkerAppearance == nil or type(HP_WorkerAppearance.vehicleAssignments) ~= "table" then + self:_logInstallWait("HP_WorkerAppearance.vehicleAssignments unavailable") + return false + end + + local runtimeManager = g_helperManager + if runtimeManager == nil then + self:_logInstallWait("g_helperManager unavailable") + return false + end + if type(runtimeManager.getRandomHelper) ~= "function" then + self:_logInstallWait("g_helperManager.getRandomHelper unavailable (type=" .. tostring(type(runtimeManager.getRandomHelper)) .. ")") + return false + end + if type(runtimeManager.releaseHelper) ~= "function" then + self:_logInstallWait("g_helperManager.releaseHelper unavailable (type=" .. tostring(type(runtimeManager.releaseHelper)) .. ")") + return false + end + + self.runtimeManager = runtimeManager + + self.originalGetRandomHelper = runtimeManager.getRandomHelper + runtimeManager.getRandomHelper = function(manager, ...) + local helper = HP_AutoDriveContinuityV5:_getPendingReacquire() + if helper ~= nil then + print(("[FS25_HelperProfiles] getRandomHelper -> '%s' (autodrive-worker-continuity)"):format(helperName(helper))) + return helper + end + return HP_AutoDriveContinuityV5.originalGetRandomHelper(manager, ...) + end + + self.originalReleaseHelper = runtimeManager.releaseHelper + runtimeManager.releaseHelper = function(manager, helper, ...) + HP_AutoDriveContinuityV5:_observeRelease(helper) + return HP_AutoDriveContinuityV5.originalReleaseHelper(manager, helper, ...) + end + + if type(HelperProfiles.isHelperActive) == "function" then + self.originalIsHelperActive = HelperProfiles.isHelperActive + HelperProfiles.isHelperActive = function(helperProfilesSelf, helper) + if HP_AutoDriveContinuityV5:isReserved(helper) then + return true + end + return HP_AutoDriveContinuityV5.originalIsHelperActive(helperProfilesSelf, helper) + end + end + + self.installed = true + self._lastWaitReason = nil + log("Installed worker-assignment continuity hooks (getRandomHelper + releaseHelper + activity bridge)") + return true +end + +function HP_AutoDriveContinuityV5:loadMap() + self.reservations = setmetatable({}, {__mode = "k"}) + self.pendingByVehicle = setmetatable({}, {__mode = "k"}) + self._lastWaitReason = nil + self._lastWaitLogMs = -100000 +end + +function HP_AutoDriveContinuityV5:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then + return + end + + if not self.installed then + if not self:install() then + return + end + end + + self:_expireStoppedPending() + self:_syncWorkerAssignments() +end + +function HP_AutoDriveContinuityV5:deleteMap() + self.reservations = setmetatable({}, {__mode = "k"}) + self.pendingByVehicle = setmetatable({}, {__mode = "k"}) +end + +-- Keep payroll accounting optional and outside the continuity algorithm. The +-- bridge is sourced here so older modDesc files on this feature branch do not +-- need a new load-order dependency; failure to load it must never disable V5. +if source ~= nil and g_currentModDirectory ~= nil then + local ok, err = pcall(source, g_currentModDirectory .. "scripts/HP_AutoDrivePayrollBridge.lua") + if not ok then + log("Optional HelperPayroll bridge failed to load: %s", tostring(err)) + end +end + +addModEventListener(HP_AutoDriveContinuityV5) diff --git a/scripts/HP_AutoDrivePayrollBridge.lua b/scripts/HP_AutoDrivePayrollBridge.lua new file mode 100644 index 0000000..796c992 --- /dev/null +++ b/scripts/HP_AutoDrivePayrollBridge.lua @@ -0,0 +1,236 @@ +-- HP_AutoDrivePayrollBridge.lua (FS25_HelperProfiles) +-- Optional HelperPayroll bridge for AutoDrive continuity sessions. +-- +-- HP_AutoDriveContinuityV5 owns the authoritative AutoDrive vehicle/helper +-- reservation. This bridge mirrors only that logical reservation lifecycle into +-- HelperPayroll's generic external-worker-session API. AutoDrive's internal +-- release/reacquire cycles never end the payroll session because the V5 +-- reservation deliberately survives those transitions. + +HP_AutoDrivePayrollBridge = HP_AutoDrivePayrollBridge or { + activeByVehicle = setmetatable({}, {__mode = "k"}), + sessionSequence = 0, + _lastWaitReason = nil, + _lastWaitLogMs = -100000 +} + +local LOG = "[FS25_HelperProfiles/AutoDrivePayroll] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function nowMs() + return tonumber(g_time) or 0 +end + +local function vehicleName(vehicle) + if vehicle ~= nil and type(vehicle.getFullName) == "function" then + local ok, value = pcall(vehicle.getFullName, vehicle) + if ok and value ~= nil and tostring(value) ~= "" then return tostring(value) end + end + if vehicle ~= nil and type(vehicle.getName) == "function" then + local ok, value = pcall(vehicle.getName, vehicle) + if ok and value ~= nil and tostring(value) ~= "" then return tostring(value) end + end + return tostring(vehicle or "unknown-vehicle") +end + +local function helperName(helper) + return tostring(helper ~= nil and helper.name or "?") +end + +local function helperSlot(helper) + local index = math.floor(tonumber(helper ~= nil and helper.index or 0) or 0) + if index < 1 or index > 20 then return nil end + return string.char(string.byte("A") + index - 1) +end + +local function ownerFarmId(vehicle) + if vehicle ~= nil and type(vehicle.getOwnerFarmId) == "function" then + local ok, value = pcall(vehicle.getOwnerFarmId, vehicle) + if ok and tonumber(value) ~= nil then return tonumber(value) end + end + if vehicle ~= nil and tonumber(vehicle.ownerFarmId) ~= nil then + return tonumber(vehicle.ownerFarmId) + end + return nil +end + +function HP_AutoDrivePayrollBridge:_logWait(reason) + local now = nowMs() + reason = tostring(reason or "unknown") + if self._lastWaitReason ~= reason or now - (tonumber(self._lastWaitLogMs) or 0) >= 10000 then + self._lastWaitReason = reason + self._lastWaitLogMs = now + log("Waiting: %s", reason) + end +end + +function HP_AutoDrivePayrollBridge:_getPayrollAPI() + local api = nil + + if g_currentMission ~= nil then + api = g_currentMission.fs25HelperPayrollAPI or g_currentMission.helperPayrollAPI + end + + if type(api) ~= "table" then + local ok, value = pcall(function() + return FS25_HelperPayroll_API or FS25_HelperPayrollAPI + end) + if ok then api = value end + end + + if type(api) ~= "table" then return nil, "HelperPayroll API unavailable" end + if type(api.beginExternalWorkerSession) ~= "function" then return nil, "HelperPayroll external-session API unavailable" end + if type(api.endExternalWorkerSession) ~= "function" then return nil, "HelperPayroll external-session finish API unavailable" end + if type(api.capabilities) == "table" and api.capabilities.externalWorkerSessions == false then + return nil, "HelperPayroll external sessions disabled" + end + + return api, nil +end + +function HP_AutoDrivePayrollBridge:_begin(vehicle, reservation) + if vehicle == nil or reservation == nil or reservation.helper == nil then return false end + + local api, reason = self:_getPayrollAPI() + if api == nil then + self:_logWait(reason) + return false + end + + local helper = reservation.helper + local slot = helperSlot(helper) + if slot == nil then + self:_logWait("reserved helper has no A-T slot") + return false + end + + self.sessionSequence = (tonumber(self.sessionSequence) or 0) + 1 + local sessionId = string.format("helperprofiles-autodrive-%d", self.sessionSequence) + local request = { + sessionId = sessionId, + source = "FS25_HelperProfiles", + controller = "AutoDrive", + jobType = "AutoDrive", + label = "AutoDrive worker", + helperSlot = slot, + helperIndex = tonumber(helper.index), + helperName = helperName(helper), + helperSlotSource = "HelperProfiles-AutoDrive-reservation", + vehicleName = vehicleName(vehicle), + farmId = ownerFarmId(vehicle) + } + + local ok, accepted, result = pcall(api.beginExternalWorkerSession, api, request) + if not ok then + self:_logWait("HelperPayroll beginExternalWorkerSession raised an error: " .. tostring(accepted)) + return false + end + if accepted ~= true then + local status = type(result) == "table" and result.status or "rejected" + self:_logWait("HelperPayroll rejected external session: " .. tostring(status)) + return false + end + + self.activeByVehicle[vehicle] = { + helper = helper, + helperIndex = tonumber(helper.index) or 0, + sessionId = sessionId, + api = api, + startedAt = nowMs() + } + self._lastWaitReason = nil + + log( + "Payroll session started: id=%s vehicle='%s' helper='%s' slot=%s", + tostring(sessionId), + vehicleName(vehicle), + helperName(helper), + tostring(slot) + ) + return true +end + +function HP_AutoDrivePayrollBridge:_finish(vehicle, active, reason) + if active == nil then return false end + + local api = active.api + if type(api) ~= "table" or type(api.endExternalWorkerSession) ~= "function" then + api = select(1, self:_getPayrollAPI()) + end + + local finished = false + local status = "api-unavailable" + if type(api) == "table" and type(api.endExternalWorkerSession) == "function" then + local ok, accepted, result = pcall(api.endExternalWorkerSession, api, active.sessionId, reason) + if ok then + finished = accepted == true + status = type(result) == "table" and tostring(result.status or (finished and "finished" or "rejected")) or tostring(accepted) + else + status = "error:" .. tostring(accepted) + end + end + + log( + "Payroll session ended: id=%s vehicle='%s' helper='%s' reason=%s accepted=%s status=%s", + tostring(active.sessionId), + vehicleName(vehicle), + helperName(active.helper), + tostring(reason or "reservation-ended"), + tostring(finished), + tostring(status) + ) + + self.activeByVehicle[vehicle] = nil + return finished +end + +function HP_AutoDrivePayrollBridge:_sync() + if HP_AutoDriveContinuityV5 == nil or type(HP_AutoDriveContinuityV5.reservations) ~= "table" then + self:_logWait("AutoDrive V5 reservation table unavailable") + return + end + + local reservations = HP_AutoDriveContinuityV5.reservations + + -- End sessions whose logical V5 reservation has genuinely disappeared or + -- changed owner. Internal AutoDrive release/reacquire transitions retain the + -- reservation, so they do not pass through this branch. + for vehicle, active in pairs(self.activeByVehicle or {}) do + local reservation = reservations[vehicle] + if reservation == nil then + self:_finish(vehicle, active, "autodrive-reservation-ended") + elseif reservation.helper ~= active.helper then + self:_finish(vehicle, active, "autodrive-reservation-owner-changed") + end + end + + -- Start payroll for any live V5 reservation that does not yet have a mirrored + -- external session. If HelperPayroll loads later, this naturally retries on a + -- subsequent update without disturbing AutoDrive continuity. + for vehicle, reservation in pairs(reservations) do + if reservation ~= nil and reservation.helper ~= nil and self.activeByVehicle[vehicle] == nil then + self:_begin(vehicle, reservation) + end + end +end + +function HP_AutoDrivePayrollBridge:loadMap() + self.activeByVehicle = setmetatable({}, {__mode = "k"}) + self.sessionSequence = 0 + self._lastWaitReason = nil + self._lastWaitLogMs = -100000 +end + +function HP_AutoDrivePayrollBridge:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end + self:_sync() +end + +function HP_AutoDrivePayrollBridge:deleteMap() + self.activeByVehicle = setmetatable({}, {__mode = "k"}) +end + +addModEventListener(HP_AutoDrivePayrollBridge) diff --git a/scripts/HP_Compatibility.lua b/scripts/HP_Compatibility.lua index ce69f43..6e2c62a 100644 --- a/scripts/HP_Compatibility.lua +++ b/scripts/HP_Compatibility.lua @@ -274,4 +274,228 @@ function HP_Compatibility:deleteMap() self.startupCheckRemainingMs = 0 end -addModEventListener(HP_Compatibility) \ No newline at end of file +addModEventListener(HP_Compatibility) + +---------------------------------------------------------------------- +-- AutoDrive helper continuity compatibility +---------------------------------------------------------------------- +-- AutoDrive can synchronously stop/restart a running mode. Its stop path releases +-- and clears the GIANTS helper, then startAutoDrive() asks getRandomHelper() for a +-- helper again. Preserve the vehicle's existing helper identity across that +-- restart window without changing normal HelperProfiles selection behaviour. + +HP_AutoDriveContinuity = HP_AutoDriveContinuity or { + _helperHookInstalled = false, + _activityHookInstalled = false, + _baseGetRandomHelper = nil, + _baseIsHelperActive = nil, + _reservations = setmetatable({}, {__mode = "k"}) +} + +local AD_LOG = "[FS25_HelperProfiles/AutoDrive] " + +local function adLog(message, ...) + print(AD_LOG .. string.format(tostring(message), ...)) +end + +local function getVehicleLabel(vehicle) + if vehicle ~= nil and vehicle.getName ~= nil then + local ok, value = pcall(vehicle.getName, vehicle) + if ok and value ~= nil and tostring(value) ~= "" then + return tostring(value) + end + end + return tostring(vehicle or "unknown-vehicle") +end + +local function getAutoDriveState(vehicle) + if vehicle == nil or vehicle.ad == nil or vehicle.ad.stateModule == nil then + return false, nil + end + + local stateModule = vehicle.ad.stateModule + local active = false + if stateModule.isActive ~= nil then + local ok, value = pcall(stateModule.isActive, stateModule) + active = ok and value == true + end + + local helperIndex = 0 + if stateModule.getCurrentHelperIndex ~= nil then + local ok, value = pcall(stateModule.getCurrentHelperIndex, stateModule) + if ok then helperIndex = tonumber(value) or 0 end + end + + return active, helperIndex +end + +local function isEngineAvailable(helper) + if helper == nil or helper.inUse == true or g_helperManager == nil then return false end + for _, candidate in ipairs(g_helperManager.availableHelpers or {}) do + if candidate == helper then return true end + end + return false +end + +function HP_AutoDriveContinuity:isEnabled() + return rawget(_G, "AutoDrive") ~= nil +end + +function HP_AutoDriveContinuity:isReserved(helper) + if helper == nil then return false end + for _, reservation in pairs(self._reservations or {}) do + if reservation ~= nil and reservation.helper == helper then + return true + end + end + return false +end + +function HP_AutoDriveContinuity:_remember(vehicle, helper, helperIndex) + if vehicle == nil or helper == nil then return end + + local existing = self._reservations[vehicle] + if existing ~= nil and existing.helper == helper then + existing.helperIndex = tonumber(helperIndex) or tonumber(helper.index) or existing.helperIndex or 0 + return + end + + self._reservations[vehicle] = { + helper = helper, + helperIndex = tonumber(helperIndex) or tonumber(helper.index) or 0 + } + + adLog( + "Driver reserved: vehicle='%s' helper='%s' index=%d", + getVehicleLabel(vehicle), + tostring(helper.name or "?"), + tonumber(helperIndex) or tonumber(helper.index) or 0 + ) +end + +function HP_AutoDriveContinuity:_release(vehicle, reason) + local reservation = self._reservations[vehicle] + if reservation == nil then return end + + adLog( + "Driver reservation cleared: vehicle='%s' helper='%s' reason=%s", + getVehicleLabel(vehicle), + tostring(reservation.helper and reservation.helper.name or "?"), + tostring(reason or "unknown") + ) + self._reservations[vehicle] = nil +end + +function HP_AutoDriveContinuity:_findReacquireCandidate() + if not self:isEnabled() or g_currentMission == nil then return nil, nil end + + local matchedVehicle = nil + local matchedHelper = nil + local matches = 0 + + for _, vehicle in pairs(g_currentMission.vehicles or {}) do + local reservation = self._reservations[vehicle] + if reservation ~= nil and reservation.helper ~= nil then + local active, helperIndex = getAutoDriveState(vehicle) + local adHelper = vehicle.ad ~= nil and vehicle.ad.currentHelper or nil + + -- AutoDrive:startAutoDrive sets active=true before calling + -- g_helperManager:getRandomHelper(). During a restart this creates a + -- distinctive active + no-current-helper window for the calling vehicle. + if active and (adHelper == nil or (tonumber(helperIndex) or 0) <= 0) and isEngineAvailable(reservation.helper) then + matches = matches + 1 + matchedVehicle = vehicle + matchedHelper = reservation.helper + end + end + end + + -- getRandomHelper has no vehicle argument, so only override when exactly one + -- AutoDrive vehicle has an unambiguous reserved-helper reacquisition window. + if matches == 1 then + return matchedHelper, matchedVehicle + end + + if matches > 1 then + adLog("Continuity skipped: %d AutoDrive vehicles are simultaneously awaiting reserved helpers", matches) + end + return nil, nil +end + +function HP_AutoDriveContinuity:_installActivityHook() + if self._activityHookInstalled then return true end + if HelperProfiles == nil or HelperProfiles.isHelperActive == nil then return false end + + self._baseIsHelperActive = HelperProfiles.isHelperActive + HelperProfiles.isHelperActive = function(helperProfilesSelf, helper) + if HP_AutoDriveContinuity ~= nil and HP_AutoDriveContinuity:isReserved(helper) then + return true + end + return HP_AutoDriveContinuity._baseIsHelperActive(helperProfilesSelf, helper) + end + + self._activityHookInstalled = true + adLog("Installed reserved-helper activity bridge") + return true +end + +function HP_AutoDriveContinuity:_installHelperHook() + if self._helperHookInstalled then return true end + if not self:isEnabled() then return false end + if HelperProfiles == nil or HelperProfiles._hooksDone ~= true then return false end + if HelperManager == nil or HelperManager.getRandomHelper == nil then return false end + + self._baseGetRandomHelper = HelperManager.getRandomHelper + HelperManager.getRandomHelper = function(manager, ...) + local helper, vehicle = HP_AutoDriveContinuity:_findReacquireCandidate() + if helper ~= nil then + adLog( + "Driver continuity reacquire: vehicle='%s' helper='%s' index=%d", + getVehicleLabel(vehicle), + tostring(helper.name or "?"), + tonumber(helper.index) or 0 + ) + return helper + end + return HP_AutoDriveContinuity._baseGetRandomHelper(manager, ...) + end + + self._helperHookInstalled = true + adLog("Installed AutoDrive getRandomHelper continuity hook") + return true +end + +function HP_AutoDriveContinuity:loadMap() + self._reservations = setmetatable({}, {__mode = "k"}) +end + +function HP_AutoDriveContinuity:update(dt) + if HP_Compatibility ~= nil and HP_Compatibility:isBlocked() then return end + + self:_installActivityHook() + self:_installHelperHook() + + if not self:isEnabled() or g_currentMission == nil then return end + + for _, vehicle in pairs(g_currentMission.vehicles or {}) do + if vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.stateModule ~= nil then + local active, helperIndex = getAutoDriveState(vehicle) + local currentHelper = vehicle.ad.currentHelper + + if active and currentHelper ~= nil then + self:_remember(vehicle, currentHelper, helperIndex) + elseif not active and self._reservations[vehicle] ~= nil then + -- AutoDrive's RestartADTask performs stopAutoDrive() and mode:start() + -- synchronously, so an internal restart does not reach this update in + -- the inactive state. A vehicle observed inactive here is a genuine stop. + self:_release(vehicle, "autodrive-stopped") + end + end + end +end + +function HP_AutoDriveContinuity:deleteMap() + self._reservations = setmetatable({}, {__mode = "k"}) +end + +addModEventListener(HP_AutoDriveContinuity)