Files
fs25-adsmartpickup/FS25_ADSmartPickup/adOutbound.lua
T

900 lines
42 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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
-- Tom vogn ved kilden så længe uden at få noget = kilden har ikke varen alligevel (paller ikke
-- spawnet, forkert farm, ...): kilden udelukkes for varen i EMPTY_SOURCE_TTL_MS og turen afsluttes.
ADOutbound.EMPTY_SOURCE_MS = 30000
ADOutbound.EMPTY_SOURCE_TTL_MS = 300000
-- Autoload-bil på vej til aflæsning: stropperne løsnes så mange meter FØR markøren. Markøren står
-- hvor næsen stopper, og en lastbils lad har passeret salgstriggeren dér; AutoDrive løsner selv
-- først ved markøren ("AL unload = center"), hvilket er for sent for en lastbil.
ADOutbound.UNSTRAP_BEFORE_M = 25
-- 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"})
-- "kilde-id:fillType" -> tidspunkt (ms) hvor udelukkelsen udløber
local emptyUntil = {}
local isInstalled = false
local function now()
if g_currentMission ~= nil and type(g_currentMission.time) == "number" then
return g_currentMission.time
end
return os.clock() * 1000
end
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
-- Lagrene en kildes læssestation trækker fra (FS25: sourceStorages = {[storage] = storage}); nil = ukendt.
local function sourceStorages(source)
-- kun siloer: en stalds mælke- og gyllepunkt deler også lager, men er forskellige læssepunkter
if source == nil or source.kind ~= "silo" then
return nil
end
local station = source.station
if station == nil or type(station.sourceStorages) ~= "table" then
return nil
end
local list = {}
for key, value in pairs(station.sourceStorages) do
table.insert(list, type(key) == "table" and key or value)
end
return #list > 0 and list or nil
end
local mergedLogged = {}
local function logMergedSources(vehicle, run, merged)
for keptId, ids in pairs(merged or {}) do
local key = tostring(run.id) .. ":" .. tostring(keptId) .. ":" .. table.concat(ids, ",")
if not mergedLogged[key] then
mergedLogged[key] = true
Logging.info(ADOutbound.LOG_PREFIX .. "run '%s': kilde %s deler lager med %s — regnes som ét stop", tostring(run.name), tostring(keptId), table.concat(ids, ", "))
end
end
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
-- UAL opgiver antal paller (læssede + 1), ikke liter: liter-kapaciteten er ukendt for en pallebil
if hasAutoload and (capacity == nil or capacity < 100) then
capacity = nil
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 salgssted for varen. Rutetjek (A*) laves kun for stationer over pristærsklen, bedste
-- først, og stopper ved den første nåelige. Er ingen over tærsklen, returneres det bedste
-- (unåelighed er ligegyldig, turen kører alligevel ikke) — til begrundelse og UI.
-- unreachableName = det bedst betalende over tærsklen, når intet over tærsklen kan nås.
local function findSellQuote(adEnv, vehicle, product, sellMarkers, sellAtPercent, homeId)
local quotes = {}
for _, entry in ipairs(sellMarkers) do
if ADSources.stationAccepts(entry.station, product.fillType) then
local quote = ADPrices.getQuote(entry.station, product.fillType)
if quote ~= nil then
table.insert(quotes, {percent = quote.percent, price = quote.price, top = quote.top, wayPointId = entry.marker.id, stationName = tostring(entry.marker.name)})
end
end
end
table.sort(quotes, function(a, b)
if a.percent ~= b.percent then return a.percent > b.percent end
if a.price ~= b.price then return a.price > b.price end
return a.wayPointId < b.wayPointId
end)
local unreachableName = nil
for _, quote in ipairs(quotes) do
if quote.percent < (sellAtPercent or 90) then
break
end
if homeId == nil or ADWaitPool.hasRouteBothWays(adEnv, vehicle, quote.wayPointId, homeId) then
return quote, nil
end
unreachableName = unreachableName or quote.stationName
end
if unreachableName ~= nil then
return nil, unreachableName
end
return quotes[1], nil
end
local function findStoreMarker(adEnv, vehicle, product, capacity, sources, litersOverride, run)
local isReachable = function(markerId)
return ADWaitPool.hasRouteTo(adEnv, vehicle, markerId)
end
-- paller lagres i et palleskur (objektlager) — UAL læsser af dér
if product.isPallet then
if ADSources.findPalletStore == nil then
return nil
end
return ADSources.findPalletStore(adEnv, vehicle, product.fillType, ADSources.getSourcePlaceables(sources), isReachable, ADSmartPickup.getFarmId(vehicle))
end
if ADLoadSwap == nil or ADLoadSwap.findDumpMarker == nil then
return nil
end
local available = litersOverride or 0
if litersOverride == nil then
for _, source in ipairs(product.sources) do
available = available + (source.available or 0)
end
end
local liters = math.max(1, math.min(available, capacity or available))
-- "Lager til" valgt i flowet: KUN de bygninger (fx gylle -> BGA, aldrig AIO-fabrikken)
local forced = run ~= nil and ADRunsController ~= nil and ADRunsController.getStoreMarkers ~= nil and ADRunsController.getStoreMarkers(adEnv, run) or {}
if #forced > 0 and ADLoadSwap.findForcedDumpMarker ~= nil then
local chosen, why = ADLoadSwap.findForcedDumpMarker(adEnv, vehicle, product.fillType, liters, forced, isReachable)
if chosen == nil then
warnOnce(adEnv, vehicle, "forced:" .. tostring(product.name) .. ":" .. tostring(why), string.format("'%s': %s kan ikke læsses af — %s", tostring(ADRuns.vehicleLabel(vehicle)), tostring(product.name), tostring(why)))
return nil
end
return {wayPointId = chosen.id, name = tostring(chosen.name), free = liters}
end
local avoid = ADRunsController ~= nil and ADRunsController.getPickupMarkers ~= nil and ADRunsController.getPickupMarkers() or nil
local marker = ADLoadSwap.findDumpMarker(adEnv, vehicle, product.fillType, liters, nil, isReachable, ADSources.getSourcePlaceables(sources), avoid, true)
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
local explanation = ADSources.explainMarker ~= nil and ADSources.explainMarker(adEnv, wayPointId, farmId) or "?"
warnOnce(adEnv, vehicle, "source:" .. tostring(wayPointId), string.format("run '%s': kilde-markør %s er ikke en fabrik, silo, stald eller objektlager — %s", tostring(run.name), tostring(wayPointId), explanation))
else
clearWarning(vehicle, "source:" .. tostring(wayPointId))
table.insert(sources, source)
sourcesById[wayPointId] = source
end
end
-- kilder der deler lager (mælketanke som udvidelser) er ét stop — ellers kører runden mellem dem
local merged
sources, merged = ADOutboundPlanner.dedupeSharedSources(sources, sourceStorages)
logMergedSources(vehicle, run, merged)
local byName, products = {}, {}
local time = now()
for _, source in ipairs(sources) do
for _, item in ipairs(ADSources.getProducts(adEnv, source, farmId)) do
local policy = ADRuns.getProductPolicy(run, item.name)
-- bulk hvis vognen kan bære varen; ellers paller hvis varen står på paller og vognen har UAL
-- (en pallebil ved sukkerfabrikken tager sukkerpallerne, en tipvogn tager bulk)
local usePallets = item.isPallet or (carriable[item.fillType] ~= true and hasAutoload and item.canPallet == true)
local canCarry = usePallets and hasAutoload or (not usePallets and carriable[item.fillType] == true)
local available = item.available
if usePallets and not item.isPallet then
available = item.palletLiters or 0
end
local emptyKey = tostring(source.wayPointId) .. ":" .. tostring(item.fillType)
local isMarkedEmpty = emptyUntil[emptyKey] ~= nil and emptyUntil[emptyKey] > time
if policy ~= "off" and canCarry and not isMarkedEmpty then
local product = byName[item.name]
if product == nil then
product = {fillType = item.fillType, name = item.name, policy = policy, isPallet = usePallets, sources = {}}
byName[item.name] = product
table.insert(products, product)
end
table.insert(product.sources, {wayPointId = source.wayPointId, available = available, level = item.level, capacity = item.capacity, palletCount = usePallets and (item.palletCount or 0) or 0})
end
end
end
table.sort(products, function(a, b) return a.name < b.name end)
-- salgsstederne findes én gang pr. plan (opslag over alle markører × stationer)
local sellMarkers = ADSources.getSellMarkers(adEnv, farmId)
for _, product in ipairs(products) do
if wantsSell(product.policy) then
local homeId = product.sources[1] ~= nil and product.sources[1].wayPointId or nil
local quote, unreachableName = findSellQuote(adEnv, vehicle, product, sellMarkers, run.sellAtPercent, homeId)
product.quote = quote
if quote == nil and unreachableName == nil then
-- intet salgssted tager varen: sig hvilke salgsmarkører der findes, og hvad de tager
local found = {}
for _, entry in ipairs(sellMarkers) do
local accepted = ADSources.describeAccepted ~= nil and ADSources.describeAccepted(entry.station) or "?"
table.insert(found, string.format("%s (%s)", tostring(entry.marker.name), accepted))
end
local key = "nosell:" .. product.name
local text = string.format("intet salgssted tager %s — salgsmarkører: %s", product.name, #found > 0 and table.concat(found, "; ") or "ingen (sæt en markør ved et salgssted)")
if warned[vehicle] == nil or warned[vehicle][key] ~= text then
warned[vehicle] = warned[vehicle] or {}
warned[vehicle][key] = text
Logging.info(ADOutbound.LOG_PREFIX .. "'%s': %s", tostring(ADRuns.vehicleLabel(vehicle)), text)
end
end
-- gårdens eget behov (fabrikker/stalde) sælges ikke; kilderne selv tæller ikke som forbrugere
if ADSources.getFarmDemand ~= nil then
local demand = ADSources.getFarmDemand(adEnv, product.fillType, farmId, ADSources.getSourcePlaceables(sources))
product.reserve, product.reservedFor = demand.liters, demand.consumers
end
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(ADRuns.vehicleLabel(vehicle)), unreachableName, product.name))
else
clearWarning(vehicle, "sell:" .. product.name)
end
end
if wantsStore(product.policy) then
product.store = findStoreMarker(adEnv, vehicle, product, capacity, sources, nil, run)
end
end
local feedMode = ADRunsController ~= nil and ADRunsController.getStoreMarkers ~= nil and #ADRunsController.getStoreMarkers(adEnv, run) > 0
local input = {
feedMode = feedMode,
capacity = capacity,
hasAutoload = hasAutoload,
sellAtPercent = run.sellAtPercent,
storeAbovePercent = run.storeAbovePercent,
collectRound = run.collectRound == true,
products = products,
}
return input, sourcesById, sources
end
-- Mål for last der ALLEREDE er på vognen (ved Start efter genindlæsning/stop): salgssted hvis
-- prisen holder, ellers silo hvis politikken tillader lager. nil = intet mål (Start afvises).
function ADOutbound.planCargoTarget(adEnv, run, vehicle, fillType, liters)
local name = fillTypeName(fillType)
local policy = ADRuns.getProductPolicy(run, name)
if policy == "off" then
return nil
end
local farmId = ADSmartPickup.getFarmId(vehicle)
local capacity = getVehicleCapacity(adEnv, vehicle)
local sources = {}
for _, wayPointId in ipairs(run.sourceWayPointIds or {}) do
local source = ADSources.identify(adEnv, wayPointId, farmId)
if source ~= nil then table.insert(sources, source) end
end
local product = {fillType = fillType, name = name, policy = policy, isPallet = false, sources = {}}
if wantsSell(policy) then
local homeId = run.sourceWayPointIds ~= nil and run.sourceWayPointIds[1] or nil
local quote = findSellQuote(adEnv, vehicle, product, ADSources.getSellMarkers(adEnv, farmId), run.sellAtPercent, homeId)
if quote ~= nil and quote.percent >= (run.sellAtPercent or 90) then
return quote.wayPointId
end
end
if wantsStore(policy) then
local store = findStoreMarker(adEnv, vehicle, product, capacity, sources, liters or capacity, run)
if store ~= nil then
return store.wayPointId
end
end
return nil
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(ADRuns.vehicleLabel(vehicle)), table.concat(names, "+"), table.concat(sourceNames, ", "), tostring(trip.targetName), why)
end
-- Lagermål i en tæt silorække: AutoDrive tipper ved den FØRSTE aflæsningstrigger inden for sin
-- globale "Max trigger distance" (standard 25 m) — nabosiloen kan så få læsset. Kan ikke sættes pr.
-- køretøj, så der advares én gang pr. mål, og brugeren sætter AD-indstillingen til 10 m.
local warnedTriggerDistance = {}
local function warnDenseSilosOnce(adEnv, vehicle, trip)
if trip.kind ~= "store" or adEnv.AutoDrive.getSetting == nil or warnedTriggerDistance[trip.targetWayPointId] then
return
end
local ok, distance = pcall(adEnv.AutoDrive.getSetting, "maxTriggerDistance")
if not ok or type(distance) ~= "number" or distance <= 10 then
return
end
local target = adEnv.ADGraphManager:getWayPointById(trip.targetWayPointId)
if target == nil then
return
end
local farmId = ADSmartPickup.getFarmId(vehicle)
for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
if marker.id ~= trip.targetWayPointId then
local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id)
local dx, dz = wayPoint ~= nil and (wayPoint.x - target.x) or math.huge, wayPoint ~= nil and (wayPoint.z - target.z) or math.huge
if dx * dx + dz * dz <= distance * distance and ADSmartPickup.getUnloadStationAtWayPointId(adEnv, marker.id, farmId) ~= nil then
warnedTriggerDistance[trip.targetWayPointId] = true
Logging.warning(ADOutbound.LOG_PREFIX .. "'%s': målet %s har en anden aflæsningsmarkør (%s) inden for AutoDrives 'Max trigger distance' (%d m) — AutoDrive kan tippe ved nabosiloen; sæt AD-indstillingen til 10 m", tostring(ADRuns.vehicleLabel(vehicle)), tostring(trip.targetName), tostring(marker.name), distance)
return
end
end
end
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(ADRuns.vehicleLabel(vehicle)), 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(ADRuns.vehicleLabel(vehicle)), 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
-- Forsynings-traktor uden noget at hente: hold ved et Wait-punkt i run'ets pulje; replan() returnerer
-- waypoint-id for næste kilde (eller nil) og kaldes hvert CHECK_INTERVAL_MS.
function ADOutbound.holdSupplyVehicle(adEnv, vehicle, run, reasons, replan)
local choice = startHold(adEnv, vehicle, run, reasons, {})
if choice ~= nil and holds[vehicle] ~= nil then
holds[vehicle].replan = replan
end
return choice
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
if ADRunsController.resolveMarkers ~= nil then
pcall(ADRunsController.resolveMarkers, adEnv, run)
end
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, ": intet lager med plads", 1, true) then
warnOnce(adEnv, vehicle, "store:" .. reason, string.format("'%s' finder intet lager med plads (%s)", tostring(ADRuns.vehicleLabel(vehicle)), reason))
end
end
if trip == nil then
return startHold(adEnv, vehicle, run, reasons, sourcesById)
end
releaseHold(vehicle)
runStatus.warning = nil
applyTrip(adEnv, vehicle, run, trip, sourcesById)
pcall(warnDenseSilosOnce, adEnv, vehicle, trip)
-- Prisgrundlaget logges, så det kan holdes op mod spillets Priser-menu (I5 i review).
if trip.kind == "sell" then
for _, product in ipairs(input.products) do
if product.fillType == trip.fillTypes[1] and product.quote ~= nil then
Logging.info(ADOutbound.LOG_PREFIX .. "'%s' pris %s: %.0f kr/1000 l ved %s, årets top %.0f -> %d %% (kræver %d)", tostring(ADRuns.vehicleLabel(vehicle)), product.name, (product.quote.price or 0) * 1000, tostring(product.quote.stationName), (product.quote.top or 0) * 1000, product.quote.percent or 0, run.sellAtPercent or 90)
end
end
end
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
-- forsynings-traktor: egen genplanlægning (ADSmartPickup) giver en kilde-markør at køre til
if hold.replan ~= nil then
local ok, targetId = pcall(hold.replan)
if ok and targetId ~= nil then
releaseHold(vehicle)
ADWaitPool.redirect(task, targetId)
task.activatedUALLoading = false
Logging.info(ADOutbound.LOG_PREFIX .. "'%s' kører fra Wait: der er noget at hente igen", tostring(ADRuns.vehicleLabel(vehicle)))
end
return true
end
ADOutbound.choosePickup(adEnv, vehicle)
local state = trips[vehicle]
if state ~= nil then
goToSource(adEnv, state, task, state.current)
return true
end
-- stadig hold, men ved en anden markør (et Wait-punkt blev ledigt): kør derhen
local newHold = holds[vehicle]
if newHold ~= nil and newHold.markerId ~= task.destinationID then
ADWaitPool.redirect(task, newHold.markerId)
task.activatedUALLoading = false
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)
local level = adEnv.AutoDrive.getAllFillLevels(task.trailers) or 0
if stored < spawn.pending.stored and (loaded > spawn.pending.loaded or level > spawn.pending.level) 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(ADRuns.vehicleLabel(vehicle)), 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), level = adEnv.AutoDrive.getAllFillLevels(task.trailers) or 0, ms = 0}
else
warnOnce(adEnv, vehicle, "spawn", string.format("'%s': kunne ikke tage %s ud af %s; kører med det der er", tostring(ADRuns.vehicleLabel(vehicle)), fillTypeName(fillType), tostring(source.name)))
state.done = true
end
end
-- Diagnose når intet bliver læsset: markørens navn, kildens mængde af turens varer og afstanden fra
-- vognen til nærmeste læssetrigger der udleverer varen (> ~10 m = vognen holder uden for triggeren).
local function describeLoadFailure(adEnv, vehicle, task, state, source)
local parts = {}
local marker = adEnv.ADGraphManager.getMapMarkerByWayPointId ~= nil and adEnv.ADGraphManager:getMapMarkerByWayPointId(state.current) or nil
table.insert(parts, string.format("markør '%s' (%s)", marker ~= nil and tostring(marker.name) or "?", tostring(state.current)))
local ok, products = pcall(ADSources.getProducts, adEnv, source, g_currentMission:getFarmId())
local wanted = {}
for _, fillType in ipairs(state.trip.fillTypes) do wanted[fillType] = true end
for _, item in ipairs(ok and products or {}) do
if wanted[item.fillType] then
table.insert(parts, string.format("%s i kilden %.0f l", fillTypeName(item.fillType), item.available or item.level or 0))
end
end
local trailer = task.trailers ~= nil and task.trailers[1] or vehicle
local station = source ~= nil and source.station or nil
if trailer ~= nil and trailer.rootNode ~= nil and station ~= nil and station.loadTriggers ~= nil then
local tx, _, tz = getWorldTranslation(trailer.rootNode)
local best = nil
for _, trigger in pairs(station.loadTriggers) do
local supplies = type(trigger.fillTypes) ~= "table" or next(trigger.fillTypes) == nil
for fillType in pairs(wanted) do
if type(trigger.fillTypes) == "table" and trigger.fillTypes[fillType] then supplies = true end
end
if supplies and trigger.triggerNode ~= nil then
local x, _, z = getWorldTranslation(trigger.triggerNode)
local d = math.sqrt((x - tx) ^ 2 + (z - tz) ^ 2)
if best == nil or d < best then best = d end
end
end
table.insert(parts, best ~= nil and string.format("vogn %.0f m fra triggeren", best) or "ingen trigger med varen")
end
local selected = vehicle.ad ~= nil and vehicle.ad.stateModule ~= nil and vehicle.ad.stateModule.getFillType ~= nil and vehicle.ad.stateModule:getFillType() or nil
table.insert(parts, "AD-vare " .. fillTypeName(selected))
return table.concat(parts, ", ")
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 level <= 0 and state.noProgressMs > ADOutbound.EMPTY_SOURCE_MS and not spawnPending then
local expires = now() + ADOutbound.EMPTY_SOURCE_TTL_MS
for _, fillType in ipairs(state.trip.fillTypes) do
emptyUntil[tostring(state.current) .. ":" .. tostring(fillType)] = expires
end
local okDiag, diagnosis = pcall(describeLoadFailure, adEnv, vehicle, task, state, source)
Logging.warning(ADOutbound.LOG_PREFIX .. "'%s': intet at læsse ved %s efter %d s; kilden springes over i %d min (%s)", tostring(ADRuns.vehicleLabel(vehicle)), tostring(source ~= nil and source.name or state.current), math.floor(ADOutbound.EMPTY_SOURCE_MS / 1000), math.floor(ADOutbound.EMPTY_SOURCE_TTL_MS / 60000), okDiag and diagnosis or tostring(diagnosis))
state.noProgressMs, state.completed = 0, true
task:finished()
return true
end
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(ADRuns.vehicleLabel(vehicle)), tostring(source ~= nil and source.name or state.current), level)
state.noProgressMs, state.completed = 0, true
task:finished()
return true
end
return false
end
local function distanceToWayPoint(adEnv, vehicle, wayPointId)
local wayPoint = adEnv.ADGraphManager:getWayPointById(wayPointId)
if wayPoint == nil or vehicle.rootNode == nil then
return nil
end
local x, _, z = getWorldTranslation(vehicle.rootNode)
local dx, dz = wayPoint.x - x, wayPoint.z - z
return math.sqrt(dx * dx + dz * dz)
end
-- Aflæsningskørsel (UnloadAtDestinationTask.update): autoload-bil med tur -> stropper af
-- UNSTRAP_BEFORE_M før målet, én gang pr. tur. Ændrer aldrig opgaven (returnerer altid false).
function ADOutbound.beforeUnloadUpdate(adEnv, task, dt)
local vehicle = task.vehicle
local state = trips[vehicle]
if state == nil or state.unstrapped or task.destinationID ~= state.trip.targetWayPointId then
return false
end
local _, hasAutoload = getVehicleCapacity(adEnv, vehicle)
if not hasAutoload then
state.unstrapped = true
return false
end
local distance = distanceToWayPoint(adEnv, vehicle, task.destinationID)
if distance == nil or distance > ADOutbound.UNSTRAP_BEFORE_M then
return false
end
state.unstrapped = true
for _, unit in ipairs(task.trailers or {}) do
if unit.setAllTensionBeltsActive ~= nil then
pcall(unit.setAllTensionBeltsActive, unit, false, false)
end
end
Logging.info(ADOutbound.LOG_PREFIX .. "'%s': stropper løsnet %.0f m før %s", tostring(ADRuns.vehicleLabel(vehicle)), distance, tostring(state.trip.targetName))
return false
end
-- Efter pålæsning: autoload-bil med tur får stropperne spændt, så lasten ikke tabes på vejen
-- (AutoDrive/UAL sætter dem ikke selv på igen efter vores løsning før forrige aflæsning).
function ADOutbound.strapAfterLoading(adEnv, task)
local vehicle = task.vehicle
local state = trips[vehicle]
if state == nil then
return
end
local _, hasAutoload = getVehicleCapacity(adEnv, vehicle)
if not hasAutoload then
return
end
local strapped = 0
for _, unit in ipairs(task.trailers or {}) do
if unit.setAllTensionBeltsActive ~= nil then
pcall(unit.setAllTensionBeltsActive, unit, true, false)
strapped = strapped + 1
end
end
state.unstrapped = false
if strapped > 0 then
Logging.info(ADOutbound.LOG_PREFIX .. "'%s': stropper spændt efter pålæsning", tostring(ADRuns.vehicleLabel(vehicle)))
end
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(ADRuns.vehicleLabel(vehicle)), 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
local result = originalFinished(self)
-- autoload-bil kører nu fra kilden: stropperne SKAL på (de løsnes 25 m før aflæsningen)
pcall(ADOutbound.strapAfterLoading, adEnv, self)
return result
end
end
local unloadTask = adEnv.UnloadAtDestinationTask
if unloadTask ~= nil and unloadTask.update ~= nil then
local originalUnloadUpdate = unloadTask.update
unloadTask.update = function(self, dt)
local okUnload, err = pcall(ADOutbound.beforeUnloadUpdate, adEnv, self, dt)
if not okUnload then
Logging.warning(ADOutbound.LOG_PREFIX .. "fejl ved strop-løsning, AutoDrive fortsætter selv: %s", tostring(err))
end
return originalUnloadUpdate(self, dt)
end
end
end