-- AD Runs Controller -- Oversætter et run til AutoDrive-tilstand pr. traktor: validering, Start/Stop, status, -- Auto-vareliste, og "hvilket run hører traktoren til". GUI'et kalder kun herind; -- de eksisterende hooks (choosePickup, Wait-punkter) spørger getRunForVehicle/getRunIngredients. ADRunsController = {} ADRunsController.LOG_PREFIX = "ADSmartPickup: " -- Længere væk fra vejnettet end dette kan AutoDrive ikke starte (samme grænse som AD's HUD). ADRunsController.MAX_NETWORK_DISTANCE = 30 ADRunsController.store = ADRuns.newStore() -- Trækkerens egne tanke (diesel/AdBlue) kan give en lille "kapacitet" uden vogn. ADRunsController.MIN_LOAD_CAPACITY = 100 -- run-id -> true når run'et er startet fra fanen (Start/Stop-knappen følger DETTE, ikke om -- traktorerne tilfældigvis kører AutoDrive — en manuelt startet traktor må ikke vende knappen). ADRunsController.runningRuns = {} local function copyList(list) local copy = {} for index, value in ipairs(list or {}) do copy[index] = value end return copy end -- Stabil nøgle: FS25's uniqueId ("vehicle…"), ellers savegame-id. Navne bruges aldrig. function ADRunsController.getVehicleId(vehicle) if vehicle.uniqueId ~= nil then return tostring(vehicle.uniqueId) end if vehicle.getUniqueId ~= nil then local id = vehicle:getUniqueId() if id ~= nil then return tostring(id) end end if vehicle.currentSavegameId ~= nil then return tostring(vehicle.currentSavegameId) end return nil end function ADRunsController.getFarmVehicles(adEnv) local farmId = g_currentMission:getFarmId() local result = {} for _, vehicle in pairs(adEnv.AutoDrive.getAllVehicles()) do -- kun motoriserede (traktorer, høstere); AD-spec'en sidder også på redskaber local isMotorized = vehicle.spec_motorized ~= nil or vehicle.spec_motorized == nil and vehicle.startAutoDrive ~= nil if vehicle.ad ~= nil and isMotorized and vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() == farmId then table.insert(result, vehicle) end end return result end -- Ét map pr. kald i stedet for at genscanne getFarmVehicles pr. vehicleId — startRun/stopRun/ -- getRunStatus/load slår flere id'er op pr. kald, og GUI'et kalder getRunStatus hvert sekund. local function getFarmVehiclesById(adEnv) local byId = {} for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do local id = ADRunsController.getVehicleId(vehicle) if id ~= nil then byId[id] = vehicle end end return byId end function ADRunsController.findVehicleById(adEnv, vehicleId) return getFarmVehiclesById(adEnv)[vehicleId] end function ADRunsController.getRunForVehicle(vehicle) local id = ADRunsController.getVehicleId(vehicle) return id ~= nil and ADRuns.findRunByVehicle(ADRunsController.store, id) or nil end -- ---- store-wrappers (erstatter altid hele store'en; modellen muterer aldrig) ---- function ADRunsController.createRun(name, kind) local store, run = ADRuns.createRun(ADRunsController.store, name, kind) ADRunsController.store = store return run end function ADRunsController.deleteRun(runId, adEnv) local run = ADRuns.getRun(ADRunsController.store, runId) if run ~= nil and adEnv ~= nil then for _, vehicleId in ipairs(run.vehicleIds) do ADRunsController.stopIfRunning(ADRunsController.findVehicleById(adEnv, vehicleId), "run slettet") end end ADRunsController.store = ADRuns.deleteRun(ADRunsController.store, runId) end -- Hver ændring logges, så et klik der ikke når modellen kan ses i log.txt. function ADRunsController.updateRun(runId, changes) ADRunsController.store = ADRuns.updateRun(ADRunsController.store, runId, changes) local run = ADRuns.getRun(ADRunsController.store, runId) local parts = {} for key, value in pairs(changes) do local text if type(value) == "table" then if key == "products" then text = ADRuns.serializeProducts(value) else local items = {} for _, item in ipairs(value) do table.insert(items, tostring(item)) end text = table.concat(items, " ") end else text = tostring(value) end table.insert(parts, string.format("%s=%s", tostring(key), text)) end table.sort(parts) Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': %s", run ~= nil and tostring(run.name) or tostring(runId), table.concat(parts, ", ")) end function ADRunsController.assignVehicle(runId, vehicle) local id = ADRunsController.getVehicleId(vehicle) if id == nil then return nil end local store, previousRunId = ADRuns.assignVehicle(ADRunsController.store, runId, id) ADRunsController.store = store return previousRunId end function ADRunsController.unassignVehicle(vehicle) local id = ADRunsController.getVehicleId(vehicle) if id ~= nil then ADRunsController.store = ADRuns.unassignVehicle(ADRunsController.store, id) ADRunsController.stopIfRunning(vehicle, "fjernet fra run") end end -- ---- varer ---- function ADRunsController.getCarriableFillTypes(adEnv, vehicle) return adEnv.AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically(vehicle) or {} end -- Auto-listen: det vognen kan bære, som målet også tager. Er målet en fabrik, indsnævrer -- findSupplyPickup selv til de ønskede produktioners inputs bagefter. -- Run'ets mål som liste (flere stalde pr. run); gamle runs har kun targetWayPointId. function ADRunsController.getRunTargets(run) if run.kind == "outbound" then return {} end if run.targetWayPointIds ~= nil and #run.targetWayPointIds > 0 then return run.targetWayPointIds end return run.targetWayPointId ~= nil and {run.targetWayPointId} or {} end local function isWaitWayPoint(adEnv, wayPointId) local marker = adEnv.ADGraphManager.getMapMarkerByWayPointId ~= nil and adEnv.ADGraphManager:getMapMarkerByWayPointId(wayPointId) or nil return marker ~= nil and ADSupplyPlanner ~= nil and ADSupplyPlanner.isWaitMarker ~= nil and ADSupplyPlanner.isWaitMarker(marker) end -- Wait-markører er ventepladser og aldrig mål, selv om de ligger inden for staldens trigger-rækkevidde. local function getTargetStations(adEnv, run, farmId) local stations = {} for _, wayPointId in ipairs(ADRunsController.getRunTargets(run)) do local station = not isWaitWayPoint(adEnv, wayPointId) and ADSmartPickup.getUnloadStationAtWayPointId(adEnv, wayPointId, farmId) or nil if station ~= nil then table.insert(stations, {wayPointId = wayPointId, station = station}) end end return stations end function ADRunsController.getTargetFillTypes(adEnv, run, vehicle) local farmId = ADSmartPickup.getFarmId(vehicle) -- En kørende traktor følger AD's eget mål (stateModule:getSecondMarker(), det samme -- choosePickup/findSupplyPickup bruger) indtil næste tur — IKKE run'ets gemte mål. -- Redigerer brugeren run'ets Mål mens tildelte traktorer kører, skal Auto-listen derfor -- følge det LIVE mål, ellers kan den blive tom og fejlagtigt smide traktoren over i -- mappe-logikken midt i turen. Inaktiv: union over alle run'ets mål. local stations = {} local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil if stateModule ~= nil and stateModule.isActive ~= nil and stateModule:isActive() and stateModule.getSecondMarker ~= nil then local secondMarker = stateModule:getSecondMarker() local station = secondMarker ~= nil and secondMarker.id ~= nil and ADSmartPickup.getUnloadStationAtWayPointId(adEnv, secondMarker.id, farmId) or nil if station ~= nil then stations = {{wayPointId = secondMarker.id, station = station}} end end if #stations == 0 then stations = getTargetStations(adEnv, run, farmId) end local result, seen = {}, {} for _, fillType in ipairs(ADRunsController.getCarriableFillTypes(adEnv, vehicle)) do for _, entry in ipairs(stations) do if not seen[fillType] and ADSmartPickup.getStationLevelAndCapacity(entry.station, fillType, farmId) ~= nil then seen[fillType] = true table.insert(result, fillType) end end end table.sort(result) return result end function ADRunsController.getRunIngredients(adEnv, run, vehicle) if run.ingredientMode == "manual" then local result = {} for _, name in ipairs(run.ingredients) do local index = g_fillTypeManager:getFillTypeIndexByName(name) if index ~= nil then table.insert(result, index) end end return result end return ADRunsController.getTargetFillTypes(adEnv, run, vehicle) end -- Alle varer målet tager (uanset vogn) — afkrydsningslisten i GUI'et. {index, name, title}, sorteret på titel. function ADRunsController.getTargetAcceptedFillTypes(adEnv, run) local farmId = g_currentMission:getFarmId() local stations = getTargetStations(adEnv, run, farmId) if #stations == 0 then return {} end local fillTypes = g_fillTypeManager.getFillTypes ~= nil and g_fillTypeManager:getFillTypes() or g_fillTypeManager.fillTypes or {} local result = {} for _, fillType in ipairs(fillTypes) do if fillType.index ~= nil then for _, entry in ipairs(stations) do if ADSmartPickup.getStationLevelAndCapacity(entry.station, fillType.index, farmId) ~= nil then table.insert(result, {index = fillType.index, name = fillType.name, title = fillType.title or fillType.name}) break end end end end table.sort(result, function(a, b) return tostring(a.title) < tostring(b.title) end) return result end -- Flere mål: den stald der mangler mest lige nu (bedste rangerede behov pr. mål; et FULDT -- læs der passer vinder). nil uden mål. Ét mål = det. function ADRunsController.chooseTarget(adEnv, run, vehicle) local targets = ADRunsController.getRunTargets(run) if #targets <= 1 then return targets[1] end local farmId = ADSmartPickup.getFarmId(vehicle) local ingredients = ADRunsController.getRunIngredients(adEnv, run, vehicle) local bestId, bestNeed = nil, nil for _, entry in ipairs(getTargetStations(adEnv, run, farmId)) do -- læs andre traktorer er på vej med til stalden, tæller med — så fordeler de sig på staldene local pending = ADSmartPickup.getPending ~= nil and ADSmartPickup.getPending(entry.wayPointId, vehicle) or nil local ranked = ADSmartPickup.getTargetNeedsRanked(entry.station, ingredients, farmId, pending) local need = ranked[1] if need ~= nil and need.freeSpace >= (ADSupplyPlanner.MIN_FREE_LITERS or 1000) then local isBetter = bestNeed == nil or (need.fits == true and bestNeed.fits ~= true) or (need.fits == bestNeed.fits and need.score > bestNeed.score) if isBetter then bestId, bestNeed = entry.wayPointId, need end end end return bestId or targets[1] end -- Fire traktorer kan hedde det samme; løbenummeret er pladsen i farmens AD-køretøjsliste. function ADRunsController.getVehicleLabel(adEnv, vehicle) for index, candidate in ipairs(ADRunsController.getFarmVehicles(adEnv)) do if candidate == vehicle then return string.format("%s (#%d)", tostring(vehicle:getName()), index) end end return tostring(vehicle:getName()) end -- ---- bygninger (flow-tavlen): bygnings-id'er -> markører ---- local function listEquals(a, b) if #a ~= #b then return false end for index = 1, #a do if a[index] ~= b[index] then return false end end return true end -- Udleder run'ets markørlister fra de valgte bygninger: udkørsel -> sourceWayPointIds (bygningens -- læssemarkør), forsyning -> targetWayPointIds (aflæsningsmarkør). Markører der ikke hører til -- nogen bygning ("rå", fra før flow-tavlen) bevares; markører der hører til en FRAVALGT bygning -- fjernes. Returnerer {missing = {bygningsnavne uden markør}}. Store opdateres kun ved ændring. function ADRunsController.resolveMarkers(adEnv, run) local missing = {} if ADBuildings == nil or run == nil then return {missing = missing} end local farmId = g_currentMission:getFarmId() local role = run.kind == "outbound" and "load" or "unload" local buildingIds = run.kind == "outbound" and run.sourceBuildingIds or run.targetBuildingIds local existing = run.kind == "outbound" and run.sourceWayPointIds or ADRunsController.getRunTargets(run) local resolved, seen = {}, {} for _, buildingId in ipairs(buildingIds or {}) do local building = ADBuildings.find(adEnv, farmId, buildingId) local markers = building ~= nil and (ADBuildings.getMarkers ~= nil and ADBuildings.getMarkers(building, role) or (role == "load" and (building.loadMarkers or {building.loadMarker}) or {building.unloadMarker})) or {} if markers[1] ~= nil then for _, marker in ipairs(markers) do if marker ~= nil and not seen[marker.id] then seen[marker.id] = true table.insert(resolved, marker.id) end end else table.insert(missing, building ~= nil and building.name or (tostring(buildingId) .. " (mangler)")) end end -- Rå markører (fra før flow-tavlen): beholdes kun hvis de er en genkendelig kilde og ikke hører til -- en bygning der allerede er valgt (så ville de dublere bygningens egne markører). local chosenPlaceables = {} for _, buildingId in ipairs(buildingIds or {}) do local building = ADBuildings.find(adEnv, farmId, buildingId) if building ~= nil and building.placeable ~= nil then chosenPlaceables[building.placeable] = true end end local pruned = {} for _, wayPointId in ipairs(existing or {}) do if not seen[wayPointId] and ADBuildings.byMarker(adEnv, farmId, wayPointId, role) == nil then local keep = true if role == "load" and ADSources ~= nil and ADSources.identify ~= nil then local source = ADSources.identify(adEnv, wayPointId, farmId) keep = source ~= nil and not (source.placeable ~= nil and chosenPlaceables[source.placeable]) elseif role == "unload" then -- mål skal være en AD-MARKØR med aflæsningsstation (et slettet markør-id er kun et waypoint, -- og AutoDrive kan ikke aflæsse dér); dubletter af valgte bygninger fjernes local marker = adEnv.ADGraphManager.getMapMarkerByWayPointId ~= nil and adEnv.ADGraphManager:getMapMarkerByWayPointId(wayPointId) or nil local station = marker ~= nil and ADSmartPickup.getUnloadStationAtWayPointId(adEnv, wayPointId, farmId) or nil keep = station ~= nil and not (station.owningPlaceable ~= nil and chosenPlaceables[station.owningPlaceable]) end seen[wayPointId] = true if keep then table.insert(resolved, wayPointId) else table.insert(pruned, tostring(wayPointId)) end end end if #pruned > 0 then Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': gamle markører fjernet (ingen kilde eller dublet af valgt bygning): %s", tostring(run.name), table.concat(pruned, " ")) end if not listEquals(resolved, existing or {}) then local changes = run.kind == "outbound" and {sourceWayPointIds = resolved} or {targetWayPointIds = resolved} ADRunsController.store = ADRuns.updateRun(ADRunsController.store, run.id, changes) local updated = ADRuns.getRun(ADRunsController.store, run.id) if updated ~= nil then run.sourceWayPointIds, run.targetWayPointIds, run.targetWayPointId = updated.sourceWayPointIds, updated.targetWayPointIds, updated.targetWayPointId end end return {missing = missing} end -- Ved indlæsning: runs fra før flow-tavlen har kun markører — oversæt dem til bygninger, hvor -- markøren er en bygnings læsse-/aflæsningsmarkør. Rå markører uden bygning beholdes. function ADRunsController.migrateBuildings(adEnv) if ADBuildings == nil then return end local farmId = g_currentMission:getFarmId() for _, run in ipairs(ADRunsController.store.runs) do local role = run.kind == "outbound" and "load" or "unload" local ids = copyList(run.kind == "outbound" and run.sourceBuildingIds or run.targetBuildingIds) local known = {} for _, id in ipairs(ids) do known[id] = true end local added = {} for _, wayPointId in ipairs(run.kind == "outbound" and run.sourceWayPointIds or ADRunsController.getRunTargets(run)) do local building = ADBuildings.byMarker(adEnv, farmId, wayPointId, role) if building ~= nil and not known[building.id] then known[building.id] = true table.insert(ids, building.id) table.insert(added, building.name) end end if #added > 0 then local changes = run.kind == "outbound" and {sourceBuildingIds = ids} or {targetBuildingIds = ids} ADRunsController.store = ADRuns.updateRun(ADRunsController.store, run.id, changes) Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': markører oversat til bygninger: %s", tostring(run.name), table.concat(added, ", ")) end end end -- Data til én række i flow-tavlen (GUI'et formaterer teksten). function ADRunsController.getFlowRow(adEnv, run) local farmId = g_currentMission:getFarmId() local row = {productNames = {}, productAuto = false, fromNames = {}, toNames = {}, fromAllSilos = run.kind ~= "outbound", missing = {}, policies = {}, vehicleNames = {}, collectRound = run.collectRound == true, sellAtPercent = run.sellAtPercent, storeAbovePercent = run.storeAbovePercent, loops = run.loops, kind = run.kind} local ids = run.kind == "outbound" and run.sourceBuildingIds or run.targetBuildingIds local names = run.kind == "outbound" and row.fromNames or row.toNames for _, buildingId in ipairs(ids or {}) do local building = ADBuildings ~= nil and ADBuildings.find(adEnv, farmId, buildingId) or nil local marker = building ~= nil and (run.kind == "outbound" and building.loadMarker or building.unloadMarker) or nil local name = building ~= nil and building.name or (tostring(buildingId) .. " (mangler)") table.insert(names, name) if marker == nil then table.insert(row.missing, name) end end -- rå markører (uden bygning) vises med markørnavn local raw = run.kind == "outbound" and run.sourceWayPointIds or ADRunsController.getRunTargets(run) for _, wayPointId in ipairs(raw or {}) do if ADBuildings == nil or ADBuildings.byMarker(adEnv, farmId, wayPointId, run.kind == "outbound" and "load" or "unload") == nil then local marker = adEnv.ADGraphManager.getMapMarkerByWayPointId ~= nil and adEnv.ADGraphManager:getMapMarkerByWayPointId(wayPointId) or nil table.insert(names, marker ~= nil and ("markør '" .. tostring(marker.name) .. "'") or ("markør " .. tostring(wayPointId))) end end if run.kind == "outbound" then for _, product in ipairs(run.products or {}) do table.insert(row.productNames, product.fillType) table.insert(row.policies, product.policy) end local status = ADRunsController.getOutboundStatus(adEnv, run) row.reason, row.warning = status.reasons[1], status.warning else row.productAuto = run.ingredientMode ~= "manual" if not row.productAuto then for _, name in ipairs(run.ingredients or {}) do table.insert(row.productNames, name) end end end local vehiclesById = getFarmVehiclesById(adEnv) for _, vehicleId in ipairs(run.vehicleIds) do local vehicle = vehiclesById[vehicleId] table.insert(row.vehicleNames, vehicle ~= nil and tostring(vehicle:getName()) or "?") end local status = ADRunsController.getRunStatus(adEnv, run) row.running, row.total, row.isRunning = status.running, status.total, status.isRunning return row end -- ---- validering ---- -- Liter-kapacitet for vognen; nil uden vogn. Universal Autoload opgiver ikke liter men ANTAL paller -- (læssede + 1), så et autoload-køretøj får nil her og markeres via isAutoload i valideringen. local function getLoadCapacity(adEnv, vehicle) local trailers = adEnv.AutoDrive.getAllUnits(vehicle) if trailers == nil then return nil end local _, capacity = adEnv.AutoDrive.getAllFillLevels(trailers) return capacity ~= nil and capacity >= ADRunsController.MIN_LOAD_CAPACITY and capacity or nil end local function intersects(listA, listB) for _, a in ipairs(listA) do for _, b in ipairs(listB) do if a == b then return true end end end return false end -- Autoload = AutoDrives eget tjek (Universal Autoload med isAutoloadAvailable og ikke autoloadDisabled, -- eller aPalletAutoLoader). Findes ingen, logges én gang pr. traktor hvad enhederne har, så det kan -- ses i log.txt hvorfor (fx UAL uden konfiguration for køretøjet). local autoloadDiagnosed = setmetatable({}, {__mode = "k"}) local function describeAutoloadUnit(unit) local name = unit.getName ~= nil and tostring(unit:getName()) or "?" local spec = unit.spec_universalAutoload if spec == nil then return string.format("%s (ingen spec_universalAutoload%s)", name, unit.spec_aPalletAutoLoader ~= nil and ", aPalletAutoLoader" or "") end return string.format("%s (isAutoloadAvailable=%s, autoloadDisabled=%s)", name, tostring(spec.isAutoloadAvailable), tostring(spec.autoloadDisabled)) end local function hasAutoload(adEnv, vehicle) local trailers = adEnv.AutoDrive.getAllUnits ~= nil and adEnv.AutoDrive.getAllUnits(vehicle) or nil for _, trailer in ipairs(trailers or {}) do if adEnv.AutoDrive.hasAL ~= nil and adEnv.AutoDrive:hasAL(trailer) then autoloadDiagnosed[vehicle] = nil return true end end if not autoloadDiagnosed[vehicle] then autoloadDiagnosed[vehicle] = true local parts = {} for _, unit in ipairs(trailers or {}) do table.insert(parts, describeAutoloadUnit(unit)) end Logging.info(ADRunsController.LOG_PREFIX .. "'%s': ingen autoload fundet på enhederne: %s", tostring(vehicle:getName()), #parts > 0 and table.concat(parts, "; ") or "(ingen enheder)") end return false end -- Er langt fra vejnettet? (AD's getDistanceFromNetwork kalder vehicle:getClosestWayPoint(), -- som ikke alle AD-køretøjer har — set in-game på en høster — så uden metoden springes tjekket over.) local function isFarFromNetwork(adEnv, vehicle) local distance = 0 if vehicle.getClosestWayPoint ~= nil and adEnv.ADGraphManager.getDistanceFromNetwork ~= nil then local ok, value = pcall(adEnv.ADGraphManager.getDistanceFromNetwork, adEnv.ADGraphManager, vehicle) distance = ok and value or 0 end return distance == nil or distance > ADRunsController.MAX_NETWORK_DISTANCE end -- ---- udkørsel ---- -- Kilder ved markørerne (fabrik/silo/objektlager) — {source} i run'ets rækkefølge; ukendte udelades. local function getRunSources(adEnv, run, farmId) local sources = {} if ADSources == nil then return sources end 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 return sources end -- Alle varer kilderne rummer/producerer med run'ets politik (uanset vogn) — til fanen Udkørsel. -- {fillType, name, title, policy, isPallet, available, level, capacity}, sorteret på titel. function ADRunsController.getRunProducts(adEnv, run) local farmId = g_currentMission:getFarmId() local byName, result = {}, {} for _, source in ipairs(getRunSources(adEnv, run, farmId)) do for _, item in ipairs(ADSources.getProducts(adEnv, source, farmId)) do local product = byName[item.name] if product == nil then product = {fillType = item.fillType, name = item.name, title = item.title or item.name, policy = ADRuns.getProductPolicy(run, item.name), isPallet = item.isPallet, canPallet = item.canPallet, available = 0, level = 0, capacity = 0} byName[item.name] = product table.insert(result, product) end product.available = product.available + (item.available or 0) product.level = product.level + (item.level or 0) product.capacity = product.capacity + (item.capacity or 0) end end table.sort(result, function(a, b) return tostring(a.title) < tostring(b.title) end) return result end -- Run'ets varer (fillType-indeks) som denne vogn kan bære: bulk via AD's liste, paller via UAL. local function getOutboundFillTypes(adEnv, run, vehicle) local carriable = {} for _, fillType in ipairs(ADRunsController.getCarriableFillTypes(adEnv, vehicle)) do carriable[fillType] = true end local canLoadPallets = hasAutoload(adEnv, vehicle) local result, palletCount, chosen = {}, 0, 0 for _, product in ipairs(ADRunsController.getRunProducts(adEnv, run)) do if product.policy ~= "off" then chosen = chosen + 1 if product.isPallet then palletCount = palletCount + 1 if canLoadPallets then table.insert(result, product.fillType) end elseif carriable[product.fillType] then table.insert(result, product.fillType) elseif product.canPallet and canLoadPallets then -- bulk-vare der også står på paller (sukkerfabrik): pallebilen tager pallerne table.insert(result, product.fillType) end end end return result, chosen, palletCount, canLoadPallets end -- Last på vognen ved Start: nil = tom vogn, waypoint-id = mål for lasten, false = intet mål. local function getCargoTarget(adEnv, run, vehicle) if ADUnloadWait == nil or ADUnloadWait.getRestLoad == nil or ADOutbound == nil or ADOutbound.planCargoTarget == nil then return nil end local fillType, liters = ADUnloadWait.getRestLoad(adEnv, vehicle) if fillType == nil or liters < 100 then return nil end return ADOutbound.planCargoTarget(adEnv, run, vehicle, fillType, liters) or false end -- Én gang pr. (run, bygningsliste): valgte bygninger uden markør springes over. local warnedMissing = {} local function warnMissingMarkers(run, missing) local text = table.concat(missing, ", ") if warnedMissing[run.id] == text then return end warnedMissing[run.id] = text Logging.warning(ADRunsController.LOG_PREFIX .. "run '%s': %s har ingen AD-markør ved triggeren (inden for 40 m) og springes over", tostring(run.name), text) end local function validateOutboundVehicle(adEnv, run, vehicle, result) local resolved = ADRunsController.resolveMarkers(adEnv, run) if resolved.missing[1] ~= nil then -- afvis kun når INGEN valgt bygning (eller rå markør) har en markør; ellers kør med de andre if #(run.sourceWayPointIds or {}) == 0 then result.reason, result.missingBuilding = "noMarker", resolved.missing[1] return result end warnMissingMarkers(run, resolved.missing) end if #getRunSources(adEnv, run, ADSmartPickup.getFarmId(vehicle)) == 0 then result.reason = "noSource" return result end if result.capacity == nil and not result.isAutoload then result.reason = "noTrailer" return result end local fillTypes, chosen, palletCount, canLoadPallets = getOutboundFillTypes(adEnv, run, vehicle) if chosen == 0 then result.reason = "noProducts" return result end if #fillTypes == 0 then result.reason = (palletCount == chosen and not canLoadPallets) and "needsAutoload" or "cannotCarry" return result end -- Uden Wait-punkt ville traktoren holde ved kilden og læsse selv om planlæggeren sagde "vent". if ADWaitPool ~= nil and #ADWaitPool.getCandidates(adEnv, run.waitPoolGroup) == 0 then result.reason = "noWaitPoint" return result end if getCargoTarget(adEnv, run, vehicle) == false then result.reason = "cargoNoTarget" return result end if isFarFromNetwork(adEnv, vehicle) then result.reason = "farFromNetwork" return result end result.ok = true return result end -- Markører med en genkendelig kilde: {marker, kind} sorteret på navn — dropdown/afkrydsning i fanen. function ADRunsController.getSourceMarkers(adEnv, farmId) local result = {} if ADSources == nil then return result end for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do local source = ADSources.identify(adEnv, marker.id, farmId) if source ~= nil then table.insert(result, {marker = marker, kind = source.kind, name = source.name}) end end table.sort(result, function(a, b) return tostring(a.marker.name) < tostring(b.marker.name) end) return result end -- Seneste planlægger-begrundelser og advarsel for run'et (fra ADOutbound) + produkter. function ADRunsController.getOutboundStatus(adEnv, run) local status = ADOutbound ~= nil and ADOutbound.getRunStatus ~= nil and ADOutbound.getRunStatus(run.id) or {reasons = {}} return {reasons = status.reasons or {}, warning = status.warning, products = ADRunsController.getRunProducts(adEnv, run)} end -- reason: "noTarget" | "noTrailer" | "cannotCarry" | "farFromNetwork" (l10n-nøgler i GUI'et); -- udkørsel desuden "noSource" | "noProducts" | "needsAutoload". function ADRunsController.validateVehicle(adEnv, run, vehicle) local carriable = ADRunsController.getCarriableFillTypes(adEnv, vehicle) local result = {ok = false, carriable = carriable, capacity = getLoadCapacity(adEnv, vehicle), isAutoload = hasAutoload(adEnv, vehicle)} if run.kind == "outbound" then return validateOutboundVehicle(adEnv, run, vehicle, result) end local resolvedIn = ADRunsController.resolveMarkers(adEnv, run) if resolvedIn.missing[1] ~= nil then if #ADRunsController.getRunTargets(run) == 0 then result.reason, result.missingBuilding = "noMarker", resolvedIn.missing[1] return result end warnMissingMarkers(run, resolvedIn.missing) end local hasTarget = false for _, wayPointId in ipairs(ADRunsController.getRunTargets(run)) do if adEnv.ADGraphManager:getMapMarkerByWayPointId(wayPointId) ~= nil then hasTarget = true end end if not hasTarget then result.reason = "noTarget" return result end -- en pallebil har ingen liter-kapacitet, men er ikke "uden vogn" — den falder på cannotCarry nedenfor if result.capacity == nil and not result.isAutoload then result.reason = "noTrailer" return result end if not intersects(ADRunsController.getRunIngredients(adEnv, run, vehicle), carriable) then result.reason = "cannotCarry" return result end if isFarFromNetwork(adEnv, vehicle) then result.reason = "farFromNetwork" return result end result.ok = true return result end -- Vognens last som INGEN af run'ets mål tager -> {marker, fillType, liters} for en silo der gør -- (plads >= resten, vejnet til siloen og fra siloen til målet); nil når lasten passer eller -- ingen silo findes (så kører AD som normalt og Wait-logikken tager over). function ADRunsController.findWrongCargoDump(adEnv, run, vehicle, targetId) if ADUnloadWait == nil or ADUnloadWait.getRestLoad == nil or ADLoadSwap == nil or ADLoadSwap.findDumpMarker == nil then return nil end local fillType, liters = ADUnloadWait.getRestLoad(adEnv, vehicle) if fillType == nil or liters < 100 then return nil end local farmId = ADSmartPickup.getFarmId(vehicle) for _, entry in ipairs(getTargetStations(adEnv, run, farmId)) do if ADSmartPickup.getStationLevelAndCapacity(entry.station, fillType, farmId) ~= nil then return nil end end local isReachable = function(markerId) return ADUnloadWait.hasRouteTo == nil or ADUnloadWait.hasRouteTo(adEnv, vehicle, markerId) end local marker, why = ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, liters, targetId, isReachable) local fillTypeName = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(fillType) or tostring(fillType) if marker == nil then Logging.warning(ADRunsController.LOG_PREFIX .. "run '%s': '%s' har %.0f l %s på vognen, som målet ikke tager — %s; AutoDrive starter som normalt", tostring(run.name), tostring(vehicle:getName()), liters, tostring(fillTypeName), tostring(why)) return nil end Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': '%s' læsser først %.0f l %s af i %s (markør %s) — målet tager ikke varen", tostring(run.name), tostring(vehicle:getName()), liters, tostring(fillTypeName), tostring(marker.name), tostring(marker.markerIndex or marker.id)) return {marker = marker, fillType = fillType, liters = liters} end -- ---- Start / Stop ---- -- AD gemmer indstillinger som INDEKS i values; useFolders er global, rotateTargets pr. køretøj. -- Er indstillingen køretøjs-specifik, men køretøjet mangler sin egen settings-tabel, skrives -- der IKKE til den globale tabel — det ville ødelægge AD's globale default for alle køretøjer. local function setSetting(adEnv, vehicle, settingName, value) local global = adEnv.AutoDrive.settings[settingName] if global == nil then return end local setting = global if global.isVehicleSpecific then if vehicle.ad.settings == nil or vehicle.ad.settings[settingName] == nil then return end setting = vehicle.ad.settings[settingName] end for index, candidate in ipairs(setting.values) do if candidate == value then setting.current = index setting.new = index return end end end -- Nærmeste markør med en læssestation: kun en gyldig start — moden vælger den reelle kilde pr. tur. local function findNearestLoadMarker(adEnv, vehicle, farmId) local x, _, z = getWorldTranslation(vehicle.rootNode) local best, bestDistance = nil, math.huge for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do if ADSmartPickup.getLoadStationAtWayPointId(adEnv, marker.id, farmId) ~= nil then local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id) -- En stale markør uden waypoint skal springes over — ellers fejler Start med "error" -- for ALLE tildelte traktorer, ikke kun den der ramte den ugyldige markør. if wayPoint ~= nil then local dx, dz = wayPoint.x - x, wayPoint.z - z local distance = dx * dx + dz * dz if distance < bestDistance then best, bestDistance = marker, distance end end end end return best end local function getSettingValue(adEnv, vehicle, settingName) local global = adEnv.AutoDrive.settings[settingName] if global == nil then return nil end local setting = global if global.isVehicleSpecific and vehicle.ad.settings ~= nil and vehicle.ad.settings[settingName] ~= nil then setting = vehicle.ad.settings[settingName] end return setting.values ~= nil and setting.values[setting.current] or nil end -- AutoDrives "AL unload" (aflæsning af autoload-vogn ved målet) står som standard på FRA (0): -- bilen ville holde ved salgsstedet uden at læsse af. "center" (1) løsner stropperne i triggeren, -- så salgsstedet tager pallerne; left/behind/right lader UAL sætte pallerne af ved siden af. -- Et valg brugeren selv har lavet, røres ikke. ADRunsController.AL_UNLOAD_CENTER = 1 local function ensureAutoloadUnload(adEnv, vehicle) if getSettingValue(adEnv, vehicle, "ALUnload") == 0 then setSetting(adEnv, vehicle, "ALUnload", ADRunsController.AL_UNLOAD_CENTER) Logging.info(ADRunsController.LOG_PREFIX .. "'%s': AutoDrives 'AL unload' var fra — sat til 'center' (stropper løsnes i triggeren)", tostring(vehicle:getName())) end end -- Udkørsel: 1. og 2. markør = første kilde (pladsholder — kilde og mål vælges pr. tur af -- ADOutbound), varevalg = run'ets varer vognen kan bære, rotation "kun pålæsning" (hook'en). local function applyOutboundRunToVehicle(adEnv, run, vehicle) local stateModule = vehicle.ad.stateModule if hasAutoload(adEnv, vehicle) then ensureAutoloadUnload(adEnv, vehicle) end stateModule:setMode(adEnv.AutoDrive.MODE_PICKUPANDDELIVER) local firstId = run.sourceWayPointIds[1] stateModule:setFirstMarkerByWayPointId(firstId) -- Læsset vogn: AD kører først til aflæsning (2. markør) — dér skal lastens mål stå. local cargoTarget = getCargoTarget(adEnv, run, vehicle) stateModule:setSecondMarkerByWayPointId(cargoTarget or firstId) local fillTypes = getOutboundFillTypes(adEnv, run, vehicle) stateModule.selectedFillTypes = copyList(fillTypes) if fillTypes[1] ~= nil then stateModule:setFillType(fillTypes[1]) end stateModule.loopCounter = 0 stateModule:setLoopsDone(0) setSetting(adEnv, vehicle, "useFolders", true) setSetting(adEnv, vehicle, "rotateTargets", adEnv.AutoDrive.RT_ONLYPICKUP) stateModule:raiseDirtyFlag() end local function applyRunToVehicle(adEnv, run, vehicle, ingredients) if run.kind == "outbound" then return applyOutboundRunToVehicle(adEnv, run, vehicle) end local stateModule = vehicle.ad.stateModule local farmId = ADSmartPickup.getFarmId(vehicle) stateModule:setMode(adEnv.AutoDrive.MODE_PICKUPANDDELIVER) -- Forkert last på vognen (fx halm fra et tidligere run, mål = sukkerfabrik): første stop -- bliver en silo der tager varen; næste tur sætter choosePickup målet tilbage til run'ets. local targetId = ADRunsController.chooseTarget(adEnv, run, vehicle) or run.targetWayPointId local dump = ADRunsController.findWrongCargoDump(adEnv, run, vehicle, targetId) stateModule:setSecondMarkerByWayPointId(targetId) if dump ~= nil then if ADUnloadWait ~= nil and ADUnloadWait.setDumpTarget ~= nil then ADUnloadWait.setDumpTarget(vehicle, dump.marker.id) else stateModule:setSecondMarkerByWayPointId(dump.marker.id) end end local loadMarker = findNearestLoadMarker(adEnv, vehicle, farmId) if loadMarker ~= nil then stateModule:setFirstMarkerByWayPointId(loadMarker.id) end stateModule.selectedFillTypes = copyList(ingredients) stateModule:setFillType(ingredients[1]) stateModule.loopCounter = run.loops stateModule:setLoopsDone(0) setSetting(adEnv, vehicle, "useFolders", true) setSetting(adEnv, vehicle, "rotateTargets", adEnv.AutoDrive.RT_ONLYPICKUP) stateModule:raiseDirtyFlag() end local function isVehicleActive(vehicle) local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil return stateModule ~= nil and stateModule.isActive ~= nil and stateModule:isActive() end -- Som ADInputManager:input_start_stop: loops nulstilles og AD stoppes. local function stopVehicle(vehicle) local stateModule = vehicle.ad.stateModule if stateModule.setLoopsDone ~= nil then stateModule:setLoopsDone(0) end vehicle:stopAutoDrive() end -- Anvend run-tilstand OG start traktoren i ét pcall'et kald: kaster vehicle:startAutoDrive() -- (fx AD's egen validering), skal den ene traktor fejle uden at hele startRun-loopet -- afbrydes — ellers når de øvrige tildelte traktorer aldrig at blive startet. -- Start præcis som AD's HUD (ADInputManager:input_start_stop): det er MODE'ens start(), -- der opretter køreopgaverne og selv kalder startAutoDrive — startAutoDrive alene sætter -- kun "aktiv", og traktoren bliver holdende. -- Kører traktoren AutoDrive i forvejen (startet manuelt eller i en gammel tilstand), stoppes -- den først, så run'ets tilstand og opgaver gælder fra nu. local function applyAndStart(adEnv, run, vehicle, ingredients) if isVehicleActive(vehicle) then stopVehicle(vehicle) end applyRunToVehicle(adEnv, run, vehicle, ingredients) local stateModule = vehicle.ad.stateModule local farmId = ADSmartPickup.getFarmId(vehicle) if stateModule.setActualFarmId ~= nil and farmId ~= nil then stateModule:setActualFarmId(farmId) end stateModule:setLoopsDone(0) local mode = stateModule.getCurrentMode ~= nil and stateModule:getCurrentMode() or nil if mode ~= nil and mode.start ~= nil then mode:start(adEnv.AutoDrive.USER_PLAYER or 1) else vehicle:startAutoDrive() end if vehicle.isServer and SpecializationUtil ~= nil and SpecializationUtil.raiseEvent ~= nil then SpecializationUtil.raiseEvent(vehicle, "onUpdate", 16, false, false, false) end end -- Pr. tildelt traktor: validér, skriv AD-tilstand, start. Afviste traktorer springes over -- og logges med årsag (samme nøgle som fanen viser). function ADRunsController.startRun(adEnv, runId) local run = ADRuns.getRun(ADRunsController.store, runId) local results = {} if run == nil then return results end local vehiclesById = getFarmVehiclesById(adEnv) local started = 0 for _, vehicleId in ipairs(run.vehicleIds) do local vehicle = vehiclesById[vehicleId] if vehicle ~= nil then local validation = ADRunsController.validateVehicle(adEnv, run, vehicle) local result = {vehicle = vehicle, ok = validation.ok, reason = validation.reason, missingBuilding = validation.missingBuilding} if not validation.ok then Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': '%s' sprunget over (%s)", tostring(run.name), tostring(vehicle:getName()), tostring(validation.reason)) end if validation.ok then local ingredients = ADRunsController.getRunIngredients(adEnv, run, vehicle) local ok, err = pcall(applyAndStart, adEnv, run, vehicle, ingredients) if ok then started = started + 1 local target = run.kind == "outbound" and ("kilde " .. tostring(run.sourceWayPointIds[1])) or ("markør " .. tostring(run.targetWayPointId)) Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': '%s' startet mod %s", tostring(run.name), tostring(vehicle:getName()), target) else result.ok, result.reason = false, "error" Logging.warning(ADRunsController.LOG_PREFIX .. "run '%s': kunne ikke starte '%s': %s", tostring(run.name), tostring(vehicle:getName()), tostring(err)) end end table.insert(results, result) end end if started > 0 then ADRunsController.runningRuns[runId] = true end return results end -- En kørende traktor der fjernes fra sit run (eller hvis run slettes) kører run'ets job — -- den stoppes, så den ikke fortsætter uden run. Returnerer true hvis den blev stoppet. function ADRunsController.stopIfRunning(vehicle, reason) if vehicle == nil or vehicle.ad == nil or not isVehicleActive(vehicle) then return false end stopVehicle(vehicle) Logging.info(ADRunsController.LOG_PREFIX .. "'%s' stoppet (%s)", tostring(vehicle:getName()), tostring(reason)) return true end function ADRunsController.stopRun(adEnv, runId) local run = ADRuns.getRun(ADRunsController.store, runId) local stopped = 0 if run == nil then return stopped end local vehiclesById = getFarmVehiclesById(adEnv) for _, vehicleId in ipairs(run.vehicleIds) do local vehicle = vehiclesById[vehicleId] if vehicle ~= nil and isVehicleActive(vehicle) then stopVehicle(vehicle) stopped = stopped + 1 Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': '%s' stoppet", tostring(run.name), tostring(vehicle:getName())) end end ADRunsController.runningRuns[runId] = nil return stopped end -- running = traktorer med AutoDrive aktiv (uanset hvem der startede dem); isRunning = run'et er -- startet fra fanen og mindst én traktor kører stadig (Start/Stop-knappen følger isRunning). function ADRunsController.getRunStatus(adEnv, run) local running = 0 local vehiclesById = getFarmVehiclesById(adEnv) for _, vehicleId in ipairs(run.vehicleIds) do local vehicle = vehiclesById[vehicleId] if vehicle ~= nil and isVehicleActive(vehicle) then running = running + 1 end end local isRunning = ADRunsController.runningRuns[run.id] == true and running > 0 if ADRunsController.runningRuns[run.id] and running == 0 then ADRunsController.runningRuns[run.id] = nil end return {running = running, total = #run.vehicleIds, isRunning = isRunning} end -- ---- overblik ---- -- Hvad laver traktoren lige nu? {kind = "stopped"|"waiting"|"loaded"|"empty", fillType, liters, place} -- place = Wait-markør, aflæsningsmål eller den silo moden valgte. Kun eksisterende data bruges. function ADRunsController.getVehicleActivity(adEnv, vehicle) if not isVehicleActive(vehicle) then return {kind = "stopped"} end local wait = ADUnloadWait ~= nil and ADUnloadWait.getWaitFor ~= nil and ADUnloadWait.getWaitFor(vehicle) or nil if wait ~= nil then return {kind = "waiting", fillType = wait.fillType, liters = wait.rest, place = wait.markerName} end local hold = ADOutbound ~= nil and ADOutbound.getHoldFor ~= nil and ADOutbound.getHoldFor(vehicle) or nil if hold ~= nil then return {kind = "holding", place = hold.markerName, reason = hold.reasons ~= nil and hold.reasons[1] or nil} end local fillType, liters = nil, 0 if ADUnloadWait ~= nil and ADUnloadWait.getRestLoad ~= nil then fillType, liters = ADUnloadWait.getRestLoad(adEnv, vehicle) end local stateModule = vehicle.ad.stateModule if fillType ~= nil and liters >= 100 then local target = stateModule.getSecondMarker ~= nil and stateModule:getSecondMarker() or nil return {kind = "loaded", fillType = fillType, liters = liters, place = target ~= nil and target.name or nil} end local pickup = ADSmartPickup.getActivePickup ~= nil and ADSmartPickup.getActivePickup(vehicle) or nil if pickup ~= nil then return {kind = "empty", fillType = pickup.fillType, place = pickup.markerName} end local first = stateModule.getFirstMarker ~= nil and stateModule:getFirstMarker() or nil return {kind = "empty", place = first ~= nil and first.name or nil} end -- Niveauer pr. mål for run'ets varer: {{wayPointId, name, levels = {{fillType, level, capacity, free}}}} -- i samme rangering som valget bruger (trug som én trang, fodervægt som prioritet); fulde sidst. function ADRunsController.getTargetsLevels(adEnv, run) local farmId = g_currentMission:getFarmId() local fillTypes = {} if run.ingredientMode == "manual" then for _, name in ipairs(run.ingredients) do local index = g_fillTypeManager:getFillTypeIndexByName(name) if index ~= nil then table.insert(fillTypes, index) end end else for _, fillType in ipairs(ADRunsController.getTargetAcceptedFillTypes(adEnv, run)) do table.insert(fillTypes, fillType.index) end end local result = {} for _, entry in ipairs(getTargetStations(adEnv, run, farmId)) do local marker = adEnv.ADGraphManager:getMapMarkerByWayPointId(entry.wayPointId) local levels = {} for _, need in ipairs(ADSmartPickup.getTargetNeedsRanked(entry.station, fillTypes, farmId)) do table.insert(levels, {fillType = need.fillType, level = need.level, capacity = need.capacity, free = need.freeSpace}) end table.insert(result, {wayPointId = entry.wayPointId, name = marker ~= nil and tostring(marker.name) or tostring(entry.wayPointId), levels = levels}) end return result end -- ---- opslag til dropdowns ---- -- Mål er steder der SKAL holdes fyldt: stalde (1) og fabrikker (2). Siloer tager alt og -- er kilder, ikke mål. Returnerer markører sorteret efter type, dernæst navn. local function getTargetKind(station) local placeable = station.owningPlaceable if placeable == nil then return nil end if placeable.getHusbandryFillLevel ~= nil then return 1 end if placeable.spec_productionPoint ~= nil then return 2 end return nil end function ADRunsController.getTargetMarkers(adEnv, farmId) local entries = {} for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do local isWait = ADSupplyPlanner ~= nil and ADSupplyPlanner.isWaitMarker ~= nil and ADSupplyPlanner.isWaitMarker(marker) local station = not isWait and ADSmartPickup.getUnloadStationAtWayPointId(adEnv, marker.id, farmId) or nil local kind = station ~= nil and getTargetKind(station) or nil if kind ~= nil then table.insert(entries, {marker = marker, kind = kind}) end end table.sort(entries, function(a, b) if a.kind ~= b.kind then return a.kind < b.kind end return tostring(a.marker.name) < tostring(b.marker.name) end) local result = {} for index, entry in ipairs(entries) do result[index] = entry.marker end return result end function ADRunsController.getWaitPools(adEnv) local counts = {} for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do if ADSupplyPlanner.isWaitMarker(marker) then counts[marker.group] = (counts[marker.group] or 0) + 1 end end local result = {} for group, count in pairs(counts) do table.insert(result, {group = group, count = count}) end table.sort(result, function(a, b) return a.group < b.group end) return result end -- ---- gem / indlæs ---- function ADRunsController.save() local ok, saved = pcall(ADRunsStorage.save, ADRunsController.store) if not ok then Logging.warning(ADRunsController.LOG_PREFIX .. "kunne ikke gemme runs: %s", tostring(saved)) return false end return saved end -- Køretøjs-referencer der ikke findes (solgt, andet savegame) logges; run'et beholdes. function ADRunsController.load(adEnv) local ok, store = pcall(ADRunsStorage.load) if not ok then Logging.warning(ADRunsController.LOG_PREFIX .. "kunne ikke indlæse runs: %s", tostring(store)) return end ADRunsController.store = store pcall(ADRunsController.migrateBuildings, adEnv) local vehiclesById = getFarmVehiclesById(adEnv) for _, run in ipairs(store.runs) do for _, vehicleId in ipairs(run.vehicleIds) do if vehiclesById[vehicleId] == nil then Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': køretøj %s findes ikke (endnu) i dette savegame", tostring(run.name), tostring(vehicleId)) end end end end function ADRunsController.installSaveHook() if ItemSystem == nil or ItemSystem.save == nil or Utils == nil then return end ItemSystem.save = Utils.prependedFunction(ItemSystem.save, function() ADRunsController.save() end) end