-- AD Buildings -- Gårdens bygninger som brugeren vælger imellem (i stedet for markører): fabrik, silo, stald, -- objektlager og salgssted, hver med den AD-markør der står nærmest bygningens læsse- og -- aflæsningstrigger (≤ MAX_MARKER_DISTANCE; Wait-markører aldrig; siloers læssemarkør via det -- strenge opslag, så tætte rækker ikke blandes). Spil-adapter med 1 s cache. ADBuildings = {} ADBuildings.LOG_PREFIX = "ADSmartPickup: " ADBuildings.MAX_MARKER_DISTANCE = 40 -- en separat drop-markør (≠ bygningens læssemarkør) må højst stå så langt fra aflæsningstriggeren ADBuildings.MAX_DROP_DISTANCE = 25 -- bygninger ændrer sig sjældent; genopbygningen (bygninger x markører) er dyr — 1 s gav et hak hvert sekund ADBuildings.CACHE_MS = 30000 local cache = {list = nil, farmId = nil, time = -math.huge} 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 getNodePosition(node) if node == nil or (entityExists ~= nil and not entityExists(node)) then return nil end local x, _, z = getWorldTranslation(node) return x, z end local function getName(placeable, fallback) if placeable ~= nil and placeable.getName ~= nil then local ok, name = pcall(placeable.getName, placeable) if ok and name ~= nil then return tostring(name) end end return fallback end function ADBuildings.getId(placeable) if placeable.uniqueId ~= nil then return tostring(placeable.uniqueId) end local x, z = getNodePosition(placeable.rootNode) if x == nil then return nil end return string.format("pos:%d:%d", math.floor(x + 0.5), math.floor(z + 0.5)) end local function getProductionPoint(placeable) return placeable.spec_productionPoint ~= nil and placeable.spec_productionPoint.productionPoint or nil end -- Fælles ejerregel (ADPrices): egen ejer eller jord-bundet kortanlæg på gårdens jord. local function isOwnedBy(placeable, farmId) if ADPrices ~= nil and ADPrices.isOwnedBy ~= nil then return ADPrices.isOwnedBy(placeable, farmId) end if placeable.getOwnerFarmId ~= nil then local ok, owner = pcall(placeable.getOwnerFarmId, placeable) return ok and owner == farmId end return placeable.ownerFarmId == farmId end local function getKind(placeable) if getProductionPoint(placeable) ~= nil then return "factory" end if placeable.spec_silo ~= nil then return "silo" end if placeable.getHusbandryFillLevel ~= nil then return "husbandry" end if placeable.spec_objectStorage ~= nil then return "objectStorage" end return nil end local logHusbandryFoodOnce ADBuildings.findTriggerNodes = function(root, maxDepth) return ADSupplyPlanner.findTriggerNodes(root, maxDepth) end -- Diagnose én gang pr. stald: hvilke felter foderdataene har, når ingen trigger-node blev fundet. local loggedFood = setmetatable({}, {__mode = "k"}) logHusbandryFoodOnce = function(placeable) if loggedFood[placeable] then return end loggedFood[placeable] = true local keys = {} for key, value in pairs(placeable.spec_husbandryFood or {}) do table.insert(keys, tostring(key) .. ":" .. type(value)) end table.sort(keys) if Logging ~= nil and Logging.info ~= nil then Logging.info(ADBuildings.LOG_PREFIX .. "stald '%s' har ingen aflæsnings-trigger; spec_husbandryFood-felter: %s", getName(placeable, "?"), table.concat(keys, ", ")) end end -- Trigger-noder pr. bygning: læsse (loadTriggers.triggerNode, pallespawner, objektlager) og -- aflæsning (unloadTriggers exactFillRootNode/triggerNode/aiNode). -- loadGroups: én gruppe noder pr. læssepunkt (mælketank, gyllebeholder ...), så hvert punkt får sin -- egen markør; pallespawner/objektlager er en gruppe for sig. local function collectNodes(placeable, loadStations, unloadStations) local loadGroups, unloadNodes = {}, {} -- én gruppe pr. LÆSSEPUNKT: en stald har ofte én station med både mælke- og gyllepunkt for _, station in pairs(loadStations) do if station.owningPlaceable == placeable then for _, trigger in pairs(station.loadTriggers or {}) do if trigger.triggerNode ~= nil then table.insert(loadGroups, {trigger.triggerNode}) end end end end for _, station in pairs(unloadStations) do if station.owningPlaceable == placeable then for _, trigger in pairs(station.unloadTriggers or {}) do for _, field in ipairs({"exactFillRootNode", "triggerNode", "aiNode"}) do if trigger[field] ~= nil then table.insert(unloadNodes, trigger[field]) end end end end end local productionPoint = getProductionPoint(placeable) local spawner = productionPoint ~= nil and productionPoint.palletSpawner or nil if spawner ~= nil then local nodes = {} for _, place in ipairs(spawner.spawnPlaces or {}) do if place.startNode ~= nil then table.insert(nodes, place.startNode) end end if #nodes > 0 then nodes.isSpawner = true table.insert(loadGroups, nodes) end end -- stald uden aflæsningsnoder i stationen (hønsehus: kun foder-triggeren) -> søg i staldens foderdata if #unloadNodes == 0 and placeable.getHusbandryFillLevel ~= nil then for _, node in ipairs(ADSupplyPlanner.findTriggerNodes(placeable.spec_husbandryFood)) do table.insert(unloadNodes, node) end if #unloadNodes == 0 then logHusbandryFoodOnce(placeable) end end -- stald med egne pallespawnere (hønsehus: æg, fårestald: uld) — én læssegruppe pr. spawner local husbandryPallets = placeable.spec_husbandryPallets local seenSpawners = {} for _, palletSpawner in pairs(husbandryPallets ~= nil and husbandryPallets.fillTypeIndexToPalletSpawner or {}) do if not seenSpawners[palletSpawner] then seenSpawners[palletSpawner] = true local nodes = {} for _, place in ipairs(palletSpawner.spawnPlaces or {}) do if place.startNode ~= nil then table.insert(nodes, place.startNode) end end if #nodes > 0 then nodes.isSpawner = true table.insert(loadGroups, nodes) end end end if placeable.spec_objectStorage ~= nil and placeable.rootNode ~= nil then table.insert(loadGroups, {placeable.rootNode}) table.insert(unloadNodes, placeable.rootNode) end return loadGroups, unloadNodes end -- Naturlig sortering: tal i navne sammenlignes som tal ("Silo 9" < "Silo 10", "25 Silo" < "26 Silo"). local function naturalKey(text) local parts = {} for chunk in string.gmatch(tostring(text or ""), "%d+%.?%d*[^%d]*") do local number, rest = string.match(chunk, "^(%d+%.?%d*)(.*)$") table.insert(parts, {tonumber(number), string.lower(rest)}) end local leading = string.match(tostring(text or ""), "^[^%d]*") if leading ~= nil and leading ~= "" then table.insert(parts, 1, {nil, string.lower(leading)}) end return parts end function ADBuildings.naturalLess(a, b) local ka, kb = naturalKey(a), naturalKey(b) for index = 1, math.max(#ka, #kb) do local pa, pb = ka[index], kb[index] if pa == nil then return true end if pb == nil then return false end if pa[1] ~= nil and pb[1] ~= nil and pa[1] ~= pb[1] then return pa[1] < pb[1] end if (pa[1] == nil) ~= (pb[1] == nil) then return pa[1] == nil end if pa[2] ~= pb[2] then return pa[2] < pb[2] end end return false end -- Bygninger sorteres efter navn (naturligt), dernæst første læssemarkørs navn (ens navne, fx 40 Farma-siloer). function ADBuildings.compare(a, b) if a.name ~= b.name then return ADBuildings.naturalLess(a.name, b.name) end local ma = a.loadMarkers ~= nil and a.loadMarkers[1] ~= nil and a.loadMarkers[1].name or (a.unloadMarker ~= nil and a.unloadMarker.name or "") local mb = b.loadMarkers ~= nil and b.loadMarkers[1] ~= nil and b.loadMarkers[1].name or (b.unloadMarker ~= nil and b.unloadMarker.name or "") return ADBuildings.naturalLess(ma, mb) end -- Nærmeste ikke-Wait-markør til en af noderne, ≤ MAX_MARKER_DISTANCE. local function nearestMarker(adEnv, nodes) local best, bestDistance = nil, ADBuildings.MAX_MARKER_DISTANCE if #nodes == 0 then return nil end local positions = {} for _, node in ipairs(nodes) do local x, z = getNodePosition(node) if x ~= nil then table.insert(positions, {x = x, z = z}) end end for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do if not ADSupplyPlanner.isWaitMarker(marker) then local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id) if wayPoint ~= nil then for _, position in ipairs(positions) do local dx, dz = wayPoint.x - position.x, wayPoint.z - position.z local distance = math.sqrt(dx * dx + dz * dz) if distance < bestDistance then best, bestDistance = marker, distance end end end end end if best == nil then return nil end return {id = best.id, name = tostring(best.name), distance = math.floor(bestDistance + 0.5)} end -- Aflæsningsmarkør: nærmeste ikke-Wait-markør ≤ MAX_MARKER_DISTANCE til aflæsningsnoderne — men ikke -- bygningens egne LÆSSEmarkører, når der findes en anden (Sugarbeet Crusher: pickup står tættere på -- aflæsningstriggeren end drop). Kun én fælles markør (AIO Load / Unload) bruges til begge. -- Alle ikke-Wait-markører ≤ MAX_MARKER_DISTANCE til nodes, nærmeste først: {{id, name, distance}}. local function markerCandidates(adEnv, nodes) local positions, found = {}, {} for _, node in ipairs(nodes) do local x, z = getNodePosition(node) if x ~= nil then table.insert(positions, {x = x, z = z}) end end for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do local wayPoint = not ADSupplyPlanner.isWaitMarker(marker) and adEnv.ADGraphManager:getWayPointById(marker.id) or nil if wayPoint ~= nil then local nearest = nil for _, position in ipairs(positions) do local distance = math.sqrt((wayPoint.x - position.x) ^ 2 + (wayPoint.z - position.z) ^ 2) if nearest == nil or distance < nearest then nearest = distance end end if nearest ~= nil and nearest < ADBuildings.MAX_MARKER_DISTANCE then table.insert(found, {id = marker.id, name = tostring(marker.name), distance = nearest}) end end end table.sort(found, function(a, b) return a.distance < b.distance end) return found end -- Læssemarkør for én læssegruppe. En pallespawner (upræcist startpunkt) viger for aflæsningen, når begge vil -- have samme markør og aflæsningen står tættere — så tager den sin næstnærmeste. Rigtige læssetriggere beholder -- markøren (Sugarbeet Crusher: pickup står tættere på aflæsningstriggeren end drop). Gælder alle bygningstyper. -- (in-game 27/9 fårestald: spawner 9 m fra "Sheep Food", foderindkast 5 m -> Goat Food fik "Sheep Milk") function ADBuildings.pickLoadMarker(candidates, unloadBest, yieldsToUnload) for _, candidate in ipairs(candidates or {}) do local isUnloads = yieldsToUnload and unloadBest ~= nil and candidate.id == unloadBest.id and unloadBest.distance < candidate.distance if not isUnloads then return {id = candidate.id, name = candidate.name, distance = math.floor(candidate.distance + 0.5)} end end return nil end -- Diagnose (én gang pr. stald pr. session): hvilke markører ligger nærmest læsse- og aflæsningspunkterne. -- (in-game 27/9: Goat Food fik "Sheep Milk"-markørerne som mål og Goat Milk Pickup "Sheep Food" som kilde) local diagnosed = {} local function closestMarkers(adEnv, nodes, count) local positions, found = {}, {} for _, node in ipairs(nodes) do local x, z = getNodePosition(node) if x ~= nil then table.insert(positions, {x = x, z = z}) end end for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do local wayPoint = not ADSupplyPlanner.isWaitMarker(marker) and adEnv.ADGraphManager:getWayPointById(marker.id) or nil if wayPoint ~= nil then local nearest = nil for _, position in ipairs(positions) do local distance = math.sqrt((wayPoint.x - position.x) ^ 2 + (wayPoint.z - position.z) ^ 2) if nearest == nil or distance < nearest then nearest = distance end end if nearest ~= nil and nearest < ADBuildings.MAX_MARKER_DISTANCE then table.insert(found, {name = tostring(marker.name), distance = nearest}) end end end table.sort(found, function(a, b) return a.distance < b.distance end) local parts = {} for index = 1, math.min(count, #found) do table.insert(parts, string.format("%s %.0f m", found[index].name, found[index].distance)) end return #parts > 0 and table.concat(parts, ", ") or "ingen markør" end local function diagnoseHusbandry(adEnv, id, name, loadGroups, unloadNodes, loadMarkers, unloadMarker) if diagnosed[id] then return end diagnosed[id] = true local groups = {} for index, nodes in ipairs(loadGroups) do table.insert(groups, string.format("læs%d: %s", index, closestMarkers(adEnv, nodes, 2))) end local loads = {} for _, marker in ipairs(loadMarkers) do table.insert(loads, tostring(marker.name)) end Logging.info("ADSmartPickup: stald '%s': %s | aflæsning nærmest: %s | valgt læs=[%s] aflæs=%s", tostring(name), #groups > 0 and table.concat(groups, "; ") or "ingen læssegrupper", closestMarkers(adEnv, unloadNodes, 3), table.concat(loads, ", "), unloadMarker ~= nil and tostring(unloadMarker.name) or "-") end local function unloadMarkerFor(adEnv, nodes, loadMarkers, allowSkipLoad) if #nodes == 0 then return nil end local isLoad = {} for _, marker in ipairs(loadMarkers or {}) do isLoad[marker.id] = true end local positions = {} for _, node in ipairs(nodes) do local x, z = getNodePosition(node) if x ~= nil then table.insert(positions, {x = x, z = z}) end end local best, bestOther = nil, nil for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do if not ADSupplyPlanner.isWaitMarker(marker) then local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id) if wayPoint ~= nil then for _, position in ipairs(positions) do local dx, dz = wayPoint.x - position.x, wayPoint.z - position.z local distance = math.sqrt(dx * dx + dz * dz) if distance < ADBuildings.MAX_MARKER_DISTANCE then if best == nil or distance < best.distance then best = {marker = marker, distance = distance} end if not isLoad[marker.id] and (bestOther == nil or distance < bestOther.distance) then bestOther = {marker = marker, distance = distance} end end end end end end -- siloer: altid nærmeste (en fælles markør må ikke erstattes af nabosiloens); ellers kun en "anden" -- markør der står tæt på aflæsningen local chosen = best if allowSkipLoad and bestOther ~= nil and bestOther.distance <= ADBuildings.MAX_DROP_DISTANCE then chosen = bestOther end if chosen == nil then return nil end return {id = chosen.marker.id, name = tostring(chosen.marker.name), distance = math.floor(chosen.distance + 0.5)} end -- Silo: den markør hvis STRENGE opslag (trigger bag næsen) rammer siloens læssestation. local function siloLoadMarker(adEnv, farmId, placeable, loadStations) local own = {} for _, station in pairs(loadStations) do if station.owningPlaceable == placeable then own[station] = true end end local best = nil for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do if not ADSupplyPlanner.isWaitMarker(marker) then local station = ADSmartPickup.getLoadStationAtWayPointId(adEnv, marker.id, farmId) if station ~= nil and own[station] then best = {id = marker.id, name = tostring(marker.name), distance = 0} break end end end return best end local function isSellPlaceable(placeable, unloadStations) for _, station in pairs(unloadStations) do if station.owningPlaceable == placeable and ADPrices.isSellingStation(station) then return true end end return false end -- Brændstof er ikke en vare der køres rundt (gårdens tankanlæg har en læssestation til diesel). local FUEL_FILL_TYPES = {DIESEL = true, DEF = true, ELECTRICCHARGE = true, METHANE = true} local function isFuelOnly(station) local supported = station.supportedFillTypes if supported == nil or next(supported) == nil or g_fillTypeManager == nil then return false end for fillType, isSupported in pairs(supported) do if isSupported and not FUEL_FILL_TYPES[tostring(g_fillTypeManager:getFillTypeNameByIndex(fillType))] then return false end end return true end local function hasOwnLoadingStation(placeable, loadStations) for _, station in pairs(loadStations) do if station.owningPlaceable == placeable and not isFuelOnly(station) then return true end end return false end local function build(adEnv, farmId) local placeables = g_currentMission ~= nil and g_currentMission.placeableSystem ~= nil and g_currentMission.placeableSystem.placeables or {} local storage = g_currentMission ~= nil and g_currentMission.storageSystem or nil local loadStations = storage ~= nil and storage:getLoadingStations() or {} local unloadStations = storage ~= nil and storage:getUnloadingStations() or {} local result = {} for _, placeable in pairs(placeables) do local kind = getKind(placeable) local isSell = kind == nil and isSellPlaceable(placeable, unloadStations) if isSell then kind = "sell" end -- generelt lager: gårdens bygning med egen læssestation uden silo/fabrik/stald-spec -- (fx Pumps&Hoses' separatorhal der udleverer digestat) if kind == nil and hasOwnLoadingStation(placeable, loadStations) then kind = "storage" end if kind ~= nil and (kind == "sell" or isOwnedBy(placeable, farmId)) then local id = ADBuildings.getId(placeable) if id ~= nil then local loadGroups, unloadNodes = collectNodes(placeable, loadStations, unloadStations) local loadMarkers, seen = {}, {} if kind == "silo" then local marker = siloLoadMarker(adEnv, farmId, placeable, loadStations) if marker ~= nil then table.insert(loadMarkers, marker) end else local unloadBest = markerCandidates(adEnv, unloadNodes)[1] for _, nodes in ipairs(loadGroups) do local marker = ADBuildings.pickLoadMarker(markerCandidates(adEnv, nodes), unloadBest, nodes.isSpawner == true) if marker ~= nil and not seen[marker.id] then seen[marker.id] = true table.insert(loadMarkers, marker) end end table.sort(loadMarkers, function(a, b) return ADBuildings.naturalLess(a.name, b.name) end) end -- siloer og palleskure læsser og aflæsser samme sted: altid nærmeste markør local unloadMarker = unloadMarkerFor(adEnv, unloadNodes, loadMarkers, kind ~= "silo" and kind ~= "objectStorage") if kind == "husbandry" then pcall(diagnoseHusbandry, adEnv, id, getName(placeable, id), loadGroups, unloadNodes, loadMarkers, unloadMarker) end -- silo der kun kan FYLDES (fx drive-on-bunker der fodrer en biogas-fermentor): aflæsning, -- ingen læssestation -> et forsyningsmål, aldrig en kilde local isFillOnlySilo = kind == "silo" and loadMarkers[1] == nil and #loadGroups == 0 and unloadMarker ~= nil table.insert(result, { id = id, name = getName(placeable, id), kind = kind, placeable = placeable, loadMarker = loadMarkers[1], loadMarkers = loadMarkers, unloadMarker = unloadMarker, canBeSource = kind ~= "sell" and not isFillOnlySilo, canBeTarget = kind == "husbandry" or kind == "factory" or isFillOnlySilo, canSell = kind == "sell", }) end end end table.sort(result, ADBuildings.compare) return result end function ADBuildings.invalidate() if ADCache ~= nil then ADCache.clear() end cache.list, cache.time = nil, -math.huge end function ADBuildings.list(adEnv, farmId) local time = now() if cache.list ~= nil and cache.farmId == farmId and (time - cache.time) < ADBuildings.CACHE_MS then return cache.list end cache.list, cache.farmId, cache.time = build(adEnv, farmId), farmId, time return cache.list end function ADBuildings.find(adEnv, farmId, id) for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do if building.id == id then return building end end return nil end -- role "load": bygningen hvis læssemarkør er wayPointId; "unload": aflæsningsmarkør. function ADBuildings.byMarker(adEnv, farmId, wayPointId, role) for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do if role == "unload" then if building.unloadMarker ~= nil and building.unloadMarker.id == wayPointId then return building end else for _, marker in ipairs(building.loadMarkers or {}) do if marker.id == wayPointId then return building end end end end return nil end -- Alle markører for en rolle: læsning = én pr. læssestation; aflæsning = én. function ADBuildings.getMarkers(building, role) if role == "unload" then return building.unloadMarker ~= nil and {building.unloadMarker} or {} end return building.loadMarkers or (building.loadMarker ~= nil and {building.loadMarker} or {}) end