Files
fs25-adsmartpickup/FS25_ADSmartPickup/adOutbound.lua
T

549 lines
23 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
-- 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