-- 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 -- "Lager til" med palleskure: første valgte der tager varen, har plads og kan nås local forcedSheds = run ~= nil and ADRunsController ~= nil and ADRunsController.getStoreBuildings ~= nil and ADRunsController.getStoreBuildings(adEnv, run) or {} local hasForcedShed, reasons = false, {} for _, building in ipairs(forcedSheds) do if building.kind == "objectStorage" then hasForcedShed = true local marker = building.unloadMarker or building.loadMarker local free = ADSources.objectStorageFreeSlots(building.placeable) local why = nil if marker == nil then why = "ingen markør" elseif not ADSources.objectStorageAccepts(building.placeable, product.fillType) then why = "palleskuret siger nej til varen" elseif free < 1 then why = "fuldt" elseif not isReachable(marker.id) then why = "ingen vej til '" .. tostring(marker.name) .. "'" end if why == nil then return {wayPointId = marker.id, name = tostring(building.name), free = free} end table.insert(reasons, string.format("%s: %s (%d ledige pladser)", tostring(building.name), why, free == math.huge and -1 or free)) end end if hasForcedShed then warnOnce(adEnv, vehicle, "forcedShed:" .. tostring(product.name) .. ":" .. table.concat(reasons, ";"), string.format("'%s': %s kan ikke sættes på lager — %s", tostring(ADRuns.vehicleLabel(vehicle)), tostring(product.name), table.concat(reasons, "; "))) return nil end 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 -- lagermål søges kun når kilden har noget af varen (ellers advarsler om æg før der er spawnet æg) local available = 0 for _, source in ipairs(product.sources) do available = available + (source.available or 0) end if wantsStore(product.policy) and available > 0 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