-- 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