feat: adOutbound — udkørsels-hooks: turvalg, hold ved Wait, objektlager-udtag, opsamlingsrunde, advarsler på skærm

This commit is contained in:
2026-09-23 09:56:28 +02:00
parent d31fbbec37
commit deb89b8c57
2 changed files with 863 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
-- AD Outbound
-- Udkørsel af produkter: før hver pålæsning vurderer planlæggeren om en tur er berettiget
-- (pris, fuldt læs, lager højt), vælger vare(r) + kilde(r) + mål og skriver det i AutoDrives
-- tilstand — AutoDrive kører, læsser (bulk eller paller via Universal Autoload) og læsser af.
-- Ingen tur: traktoren holder ved et Wait-punkt og spørger igen hvert 5. sekund.
-- Ved et objektlager bedes lageret spawne én palle ad gangen, som UAL tager.
-- Opsamlingsrunde: bulk = samme vare fra flere kilder; paller = flere varer på én tur.
--
-- Hook 1: ADSmartPickup's getNextPickup-wrap kalder choosePickup for udkørsels-traktorer.
-- Hook 2: LoadAtDestinationTask.update/finished (install) — hold, udtag, fremdriftsvagt, runde.
ADOutbound = {}
ADOutbound.LOG_PREFIX = "ADSmartPickup: "
-- Holdende traktorer spørger planlæggeren så tit (opslag over alle markører).
ADOutbound.CHECK_INTERVAL_MS = 5000
-- "Kør uanset mængde": stiger vognens fyldning ikke så længe ved kilden, køres der med det der er.
ADOutbound.NO_PROGRESS_MS = 15000
-- Objektlager der ikke leverer en palle inden så længe regnes som fastlåst (kendt spilfejl).
ADOutbound.SPAWN_STALL_MS = 30000
-- UAL kan først læsse når vognen holder helt stille (AutoDrives egen grænse).
ADOutbound.MAX_SPEED_FOR_LOADING = 0.0005
ADOutbound.NOTIFICATION_MS = 8000
-- køretøj -> {run, trip, sources, queue, visited, current, lastFill, noProgressMs, spawn, done}
local trips = setmetatable({}, {__mode = "k"})
-- køretøj -> {markerId, markerName, reasons, timerMs, lastLogged}
local holds = setmetatable({}, {__mode = "k"})
-- run-id -> {reasons, warning}
local status = {}
-- køretøj -> {[nøgle] = tekst} (advarsler vises én gang pr. ændret tekst)
local warned = setmetatable({}, {__mode = "k"})
local isInstalled = false
local function getRun(vehicle)
return ADRunsController ~= nil and ADRunsController.getRunForVehicle ~= nil and ADRunsController.getRunForVehicle(vehicle) or nil
end
function ADOutbound.isOutboundVehicle(vehicle)
local run = getRun(vehicle)
return run ~= nil and run.kind == "outbound"
end
local function fillTypeName(fillType)
return g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(fillType) or tostring(fillType)
end
local function getStatus(run)
status[run.id] = status[run.id] or {}
return status[run.id]
end
-- ---- advarsler (log + rød AutoDrive-notifikation på skærmen, én gang pr. ændret tekst) ----
local function warnOnce(adEnv, vehicle, key, text)
warned[vehicle] = warned[vehicle] or {}
if warned[vehicle][key] == text then
return
end
warned[vehicle][key] = text
Logging.warning(ADOutbound.LOG_PREFIX .. "%s", text)
local event, messages = adEnv.AutoDriveMessageEvent, adEnv.ADMessagesManager
if event ~= nil and event.sendMessageOrNotification ~= nil and messages ~= nil and messages.messageTypes ~= nil then
pcall(event.sendMessageOrNotification, vehicle, messages.messageTypes.ERROR, "%s", ADOutbound.NOTIFICATION_MS, "ADSmartPickup: " .. text)
end
local run = getRun(vehicle)
if run ~= nil then
getStatus(run).warning = text
end
end
local function clearWarning(vehicle, key)
if warned[vehicle] ~= nil then
warned[vehicle][key] = nil
end
end
-- ---- planlægger-input fra spillet ----
local function getVehicleCapacity(adEnv, vehicle)
local trailers = adEnv.AutoDrive.getAllUnits ~= nil and adEnv.AutoDrive.getAllUnits(vehicle) or nil
if trailers == nil then
return nil, false, nil
end
local _, capacity = adEnv.AutoDrive.getAllFillLevels(trailers)
local hasAutoload = false
for _, trailer in ipairs(trailers) do
if adEnv.AutoDrive.hasAL ~= nil and adEnv.AutoDrive:hasAL(trailer) then
hasAutoload = true
end
end
return capacity, hasAutoload, trailers
end
local function wantsSell(policy) return policy == "sell" or policy == "sellElseStore" end
local function wantsStore(policy) return policy == "store" or policy == "sellElseStore" end
-- Bedste NÅELIGE salgssted for varen; unreachableName = navnet på det bedste der ikke kan nås.
local function findSellQuote(adEnv, vehicle, product, farmId)
local quotes, unreachableName = {}, nil
local homeId = product.sources[1] ~= nil and product.sources[1].wayPointId or nil
for _, entry in ipairs(ADSources.getSellMarkers(adEnv, farmId)) do
if ADSources.stationAccepts(entry.station, product.fillType) then
local quote = ADPrices.getQuote(entry.station, product.fillType)
if quote ~= nil then
local reachable = homeId == nil or ADWaitPool.hasRouteBothWays(adEnv, vehicle, entry.marker.id, homeId)
if reachable then
table.insert(quotes, {percent = quote.percent, price = quote.price, top = quote.top, wayPointId = entry.marker.id, stationName = tostring(entry.marker.name)})
elseif unreachableName == nil then
unreachableName = tostring(entry.marker.name)
end
end
end
end
return ADPrices.bestQuote(quotes), unreachableName
end
local function findStoreMarker(adEnv, vehicle, product, capacity, sources)
if product.isPallet or ADLoadSwap == nil or ADLoadSwap.findDumpMarker == nil then
return nil
end
local available = 0
for _, source in ipairs(product.sources) do
available = available + (source.available or 0)
end
local liters = math.max(1, math.min(available, capacity or available))
local isReachable = function(markerId)
return ADWaitPool.hasRouteTo(adEnv, vehicle, markerId)
end
local marker = ADLoadSwap.findDumpMarker(adEnv, vehicle, product.fillType, liters, nil, isReachable, ADSources.getSourcePlaceables(sources))
if marker == nil then
return nil
end
return {wayPointId = marker.id, name = tostring(marker.name), free = liters}
end
-- input til ADOutboundPlanner.plan + kilder pr. waypoint-id.
function ADOutbound.buildPlanInput(adEnv, run, vehicle)
local farmId = ADSmartPickup.getFarmId(vehicle)
local capacity, hasAutoload = getVehicleCapacity(adEnv, vehicle)
local carriable = {}
for _, fillType in ipairs(adEnv.AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically(vehicle) or {}) do
carriable[fillType] = true
end
local sources, sourcesById = {}, {}
for _, wayPointId in ipairs(run.sourceWayPointIds or {}) do
local source = ADSources.identify(adEnv, wayPointId, farmId)
if source == nil then
warnOnce(adEnv, vehicle, "source:" .. tostring(wayPointId), string.format("run '%s': kilde-markør %s er ikke en fabrik, silo eller objektlager (inden for 40 m)", tostring(run.name), tostring(wayPointId)))
else
clearWarning(vehicle, "source:" .. tostring(wayPointId))
table.insert(sources, source)
sourcesById[wayPointId] = source
end
end
local byName, products = {}, {}
for _, source in ipairs(sources) do
for _, item in ipairs(ADSources.getProducts(adEnv, source, farmId)) do
local policy = ADRuns.getProductPolicy(run, item.name)
local canCarry = (item.isPallet and hasAutoload) or (not item.isPallet and carriable[item.fillType] == true)
if policy ~= "off" and canCarry then
local product = byName[item.name]
if product == nil then
product = {fillType = item.fillType, name = item.name, policy = policy, isPallet = item.isPallet, sources = {}}
byName[item.name] = product
table.insert(products, product)
end
table.insert(product.sources, {wayPointId = source.wayPointId, available = item.available, level = item.level, capacity = item.capacity})
end
end
end
table.sort(products, function(a, b) return a.name < b.name end)
for _, product in ipairs(products) do
if wantsSell(product.policy) then
local quote, unreachableName = findSellQuote(adEnv, vehicle, product, farmId)
product.quote = quote
if quote == nil and unreachableName ~= nil then
warnOnce(adEnv, vehicle, "sell:" .. product.name, string.format("'%s' kan ikke nå salgssted '%s' for %s – tjek vejnettet", tostring(vehicle:getName()), unreachableName, product.name))
else
clearWarning(vehicle, "sell:" .. product.name)
end
end
if wantsStore(product.policy) then
product.store = findStoreMarker(adEnv, vehicle, product, capacity, sources)
end
end
local input = {
capacity = capacity,
hasAutoload = hasAutoload,
sellAtPercent = run.sellAtPercent,
storeAbovePercent = run.storeAbovePercent,
collectRound = run.collectRound == true,
products = products,
}
return input, sourcesById, sources
end
-- ---- tur ----
local function copyList(list)
local copy = {}
for index, value in ipairs(list or {}) do copy[index] = value end
return copy
end
local function choiceFor(marker, source, trip)
return {
markerIndex = marker.markerIndex,
markerName = marker.name,
group = marker.group,
station = source ~= nil and source.station or nil,
fillTypes = trip ~= nil and copyList(trip.fillTypes) or {},
fillType = trip ~= nil and trip.fillTypes[1] or nil,
}
end
local function getMarker(adEnv, wayPointId)
return adEnv.ADGraphManager:getMapMarkerByWayPointId(wayPointId)
end
-- Ved et objektlager tager vi selv varerne én ad gangen; AD må ikke selv rotere varevalget
-- (det gør den kun med >1 valgt), så dér står kun turens første vare i AD's liste.
local function applyTrip(adEnv, vehicle, run, trip, sourcesById)
local stateModule = vehicle.ad.stateModule
local first = sourcesById[trip.sourceIds[1]]
local selected = copyList(trip.fillTypes)
if first ~= nil and first.kind == "objectStorage" then
selected = {trip.fillTypes[1]}
end
stateModule.selectedFillTypes = selected
stateModule:setFillType(trip.fillTypes[1])
if stateModule.setSecondMarkerByWayPointId ~= nil then
stateModule:setSecondMarkerByWayPointId(trip.targetWayPointId)
end
if stateModule.raiseDirtyFlag ~= nil then
stateModule:raiseDirtyFlag()
end
trips[vehicle] = {run = run, trip = trip, sources = sourcesById, queue = copyList(trip.sourceIds), visited = {}, current = trip.sourceIds[1], noProgressMs = 0}
local names = {}
for _, fillType in ipairs(trip.fillTypes) do table.insert(names, fillTypeName(fillType)) end
local sourceNames = {}
for _, id in ipairs(trip.sourceIds) do table.insert(sourceNames, sourcesById[id] ~= nil and sourcesById[id].name or tostring(id)) end
local why = trip.kind == "sell" and string.format("salg, %d %% af top", trip.percent or 0) or "lager"
Logging.info(ADOutbound.LOG_PREFIX .. "'%s' udkørsel: %s fra %s → %s (%s)", tostring(vehicle:getName()), table.concat(names, "+"), table.concat(sourceNames, ", "), tostring(trip.targetName), why)
end
local function releaseHold(vehicle)
if holds[vehicle] ~= nil then
ADWaitPool.releaseVehicle(vehicle)
holds[vehicle] = nil
end
end
local function startHold(adEnv, vehicle, run, reasons, sourcesById)
local isReachable = function(candidate)
return ADWaitPool.hasRouteTo(adEnv, vehicle, candidate.marker.id)
end
local candidate = ADWaitPool.pickFree(adEnv, vehicle, run.waitPoolGroup, isReachable)
local marker, source = nil, nil
if candidate ~= nil then
ADWaitPool.claim(candidate.marker.id, vehicle)
marker = candidate.marker
clearWarning(vehicle, "noWait")
else
local firstId = run.sourceWayPointIds ~= nil and run.sourceWayPointIds[1] or nil
marker = firstId ~= nil and getMarker(adEnv, firstId) or nil
source = firstId ~= nil and sourcesById[firstId] or nil
if marker == nil then
return nil
end
warnOnce(adEnv, vehicle, "noWait", string.format("'%s' har intet ledigt Wait-punkt med vejnet (run '%s'); holder ved kilden %s", tostring(vehicle:getName()), tostring(run.name), tostring(marker.name)))
end
local previous = holds[vehicle]
holds[vehicle] = {markerId = marker.id, markerName = tostring(marker.name), reasons = reasons, timerMs = 0}
local message = string.format("'%s' holder ved %s: %s", tostring(vehicle:getName()), tostring(marker.name), table.concat(reasons, "; "))
if previous == nil or previous.lastLogged ~= message then
Logging.info(ADOutbound.LOG_PREFIX .. "%s", message)
end
holds[vehicle].lastLogged = message
return choiceFor(marker, source, nil)
end
-- Hook 1: AutoDrive spørger om næste pålæsning. Tur -> kilden; ingen tur -> Wait-punkt.
function ADOutbound.choosePickup(adEnv, vehicle)
local run = getRun(vehicle)
if run == nil or run.kind ~= "outbound" then
return nil
end
trips[vehicle] = nil
local input, sourcesById = ADOutbound.buildPlanInput(adEnv, run, vehicle)
local trip, reasons = ADOutboundPlanner.plan(input)
local runStatus = getStatus(run)
runStatus.reasons = reasons
for _, reason in ipairs(reasons) do
if string.find(reason, ": ingen silo med plads", 1, true) then
warnOnce(adEnv, vehicle, "store:" .. reason, string.format("'%s' finder ingen silo med plads (%s)", tostring(vehicle:getName()), reason))
end
end
if trip == nil then
return startHold(adEnv, vehicle, run, reasons, sourcesById)
end
releaseHold(vehicle)
runStatus.warning = nil
warned[vehicle] = nil
applyTrip(adEnv, vehicle, run, trip, sourcesById)
local marker = getMarker(adEnv, trip.sourceIds[1])
if marker == nil then
return nil
end
return choiceFor(marker, sourcesById[trip.sourceIds[1]], trip)
end
-- ---- Hook 2: læsseopgaven ----
local function setActivePickup(vehicle, choice)
if ADSmartPickup ~= nil and ADSmartPickup.setActivePickup ~= nil then
ADSmartPickup.setActivePickup(vehicle, choice)
end
end
local function goToSource(adEnv, state, task, wayPointId)
state.current = wayPointId
state.noProgressMs, state.lastFill, state.spawn, state.done, state.completed = 0, nil, nil, false, false
ADWaitPool.redirect(task, wayPointId)
task.activatedUALLoading = false
task.spuCargoChecked = nil
local marker = getMarker(adEnv, wayPointId)
if marker ~= nil then
setActivePickup(task.vehicle, choiceFor(marker, state.sources[wayPointId], state.trip))
end
end
local function updateHold(adEnv, task, hold, dt)
local vehicle = task.vehicle
if task.destinationID ~= hold.markerId or not vehicle.ad.drivePathModule:isTargetReached() then
return false
end
vehicle.ad.specialDrivingModule:stopVehicle()
vehicle.ad.specialDrivingModule:update(dt)
hold.timerMs = hold.timerMs + (dt or 0)
if hold.timerMs < ADOutbound.CHECK_INTERVAL_MS then
return true
end
hold.timerMs = 0
ADOutbound.choosePickup(adEnv, vehicle)
local state = trips[vehicle]
if state ~= nil then
goToSource(adEnv, state, task, state.current)
end
return true
end
local function countLoaded(trailers)
local count = 0
for _, trailer in ipairs(trailers or {}) do
local spec = trailer.spec_universalAutoload
for _ in pairs(spec ~= nil and spec.loadedObjects or {}) do
count = count + 1
end
end
return count
end
local function isAutoloadFull(trailers)
for _, trailer in ipairs(trailers or {}) do
if trailer.ualIsFull ~= nil and trailer:ualIsFull() then
return true
end
end
return false
end
-- Objektlager: én palle ad gangen — spawn, vent til lageret er én mindre OG vognen har én
-- mere, gentag; skift vare når lageret er tomt for den aktuelle; stop ved fuld vogn.
local function updateSpawn(adEnv, state, task, source, dt)
local vehicle = task.vehicle
-- AD aktiverede selv UAL med turens første vare; kun ved vareskift genaktiveres den.
local spawn = state.spawn or {typeIndex = 1, activeType = state.trip.fillTypes[1]}
state.spawn = spawn
if not task.activatedUALLoading or (vehicle.lastSpeedReal or 0) >= ADOutbound.MAX_SPEED_FOR_LOADING then
return
end
local fillType = state.trip.fillTypes[spawn.typeIndex]
if spawn.pending ~= nil then
spawn.pending.ms = spawn.pending.ms + (dt or 0)
local stored, loaded = ADSources.countStored(source, fillType), countLoaded(task.trailers)
if stored < spawn.pending.stored and loaded > spawn.pending.loaded then
spawn.pending = nil
elseif spawn.pending.ms > ADOutbound.SPAWN_STALL_MS then
warnOnce(adEnv, vehicle, "spawn", string.format("'%s': objektlageret %s spawner ikke %s (30 s uden fremdrift); kører med det der er", tostring(vehicle:getName()), tostring(source.name), fillTypeName(fillType)))
state.done = true
return
else
return
end
end
if isAutoloadFull(task.trailers) or fillType == nil then
state.done = true
return
end
local index = ADSources.getObjectInfoIndex(source, fillType)
if index == nil then
spawn.typeIndex = spawn.typeIndex + 1
return
end
if spawn.activeType ~= fillType then
spawn.activeType = fillType
vehicle.ad.stateModule:setFillType(fillType)
if adEnv.AutoDrive.activateALTrailers ~= nil then
adEnv.AutoDrive.activateALTrailers(vehicle, task.trailers)
end
end
if ADSources.spawnOne(source, index) then
spawn.pending = {stored = ADSources.countStored(source, fillType), loaded = countLoaded(task.trailers), ms = 0}
else
warnOnce(adEnv, vehicle, "spawn", string.format("'%s': kunne ikke tage %s ud af %s; kører med det der er", tostring(vehicle:getName()), fillTypeName(fillType), tostring(source.name)))
state.done = true
end
end
-- true = håndteret (original update springes over: traktoren holder ved Wait).
function ADOutbound.beforeLoadUpdate(adEnv, task, dt)
local vehicle = task.vehicle
local hold = holds[vehicle]
if hold ~= nil then
return updateHold(adEnv, task, hold, dt)
end
local state = trips[vehicle]
if state == nil or state.completed or task.destinationID ~= state.current or not vehicle.ad.drivePathModule:isTargetReached() then
return false
end
local source = state.sources[state.current]
if source ~= nil and source.kind == "objectStorage" then
updateSpawn(adEnv, state, task, source, dt)
if state.done then
state.done, state.completed = false, true
task:finished()
return true
end
end
local level = adEnv.AutoDrive.getAllFillLevels(task.trailers) or 0
if state.lastFill == nil or level > state.lastFill then
state.lastFill, state.noProgressMs = level, 0
else
state.noProgressMs = state.noProgressMs + (dt or 0)
end
local spawnPending = state.spawn ~= nil and state.spawn.pending ~= nil
if state.noProgressMs > ADOutbound.NO_PROGRESS_MS and level > 0 and not spawnPending then
Logging.info(ADOutbound.LOG_PREFIX .. "'%s': ingen mere last ved %s; kører med %.0f l", tostring(vehicle:getName()), tostring(source ~= nil and source.name or state.current), level)
state.noProgressMs, state.completed = 0, true
task:finished()
return true
end
return false
end
-- true = opsamlingsrunden fortsætter til næste kilde (original finished springes over).
function ADOutbound.beforeLoadFinished(adEnv, task)
local vehicle = task.vehicle
local state = trips[vehicle]
if state == nil or task.destinationID ~= state.current then
return false
end
state.visited[state.current] = true
local level, capacity, filledToUnload, free = adEnv.AutoDrive.getAllFillLevels(task.trailers)
if filledToUnload or capacity == nil or capacity <= 0 or (free or 0) / capacity < ADOutboundPlanner.ROUND_MIN_FREE_FRACTION or isAutoloadFull(task.trailers) then
return false
end
for _, wayPointId in ipairs(state.queue) do
if not state.visited[wayPointId] and state.sources[wayPointId] ~= nil then
Logging.info(ADOutbound.LOG_PREFIX .. "'%s' runde: %.0f l på vognen, videre til %s", tostring(vehicle:getName()), level or 0, tostring(state.sources[wayPointId].name))
goToSource(adEnv, state, task, wayPointId)
return true
end
end
return false
end
-- ---- oprydning og opslag ----
local function isActive(vehicle)
local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
return stateModule ~= nil and stateModule.isActive ~= nil and stateModule:isActive()
end
function ADOutbound.releaseInactive()
for vehicle in pairs(holds) do
if not isActive(vehicle) then
releaseHold(vehicle)
end
end
for vehicle in pairs(trips) do
if not isActive(vehicle) then
trips[vehicle] = nil
end
end
end
function ADOutbound.getHoldFor(vehicle)
local hold = holds[vehicle]
if hold == nil then
return nil
end
return {markerName = hold.markerName, reasons = hold.reasons}
end
function ADOutbound.getTripFor(vehicle)
local state = trips[vehicle]
return state ~= nil and state.trip or nil
end
function ADOutbound.getRunStatus(runId)
return status[runId] or {reasons = {}}
end
function ADOutbound.install(adEnv)
local task = adEnv.LoadAtDestinationTask
if isInstalled or task == nil or task.update == nil then
return
end
isInstalled = true
ADWaitPool.STATE_DRIVING = task.STATE_DRIVING or ADWaitPool.STATE_DRIVING
local originalUpdate, originalFinished = task.update, task.finished
task.update = function(self, dt)
local ok, handled = pcall(ADOutbound.beforeLoadUpdate, adEnv, self, dt)
if not ok then
Logging.warning(ADOutbound.LOG_PREFIX .. "fejl i udkørsel, AutoDrive fortsætter selv: %s", tostring(handled))
releaseHold(self.vehicle)
trips[self.vehicle] = nil
elseif handled then
return
end
return originalUpdate(self, dt)
end
if originalFinished ~= nil then
task.finished = function(self)
local ok, continueRound = pcall(ADOutbound.beforeLoadFinished, adEnv, self)
if not ok then
Logging.warning(ADOutbound.LOG_PREFIX .. "fejl i opsamlingsrunde, AutoDrive fortsætter selv: %s", tostring(continueRound))
elseif continueRound then
return
end
return originalFinished(self)
end
end
end