commit 63afbba1f8137f2edd1fd4d3919e8fc311540626 Author: masterdraco Date: Mon Sep 21 00:32:59 2026 +0200 feat: AD Smart Pickup — vare-bevidst mappe-rotation (mindst fyldte silo først) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4c4ffc --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.zip diff --git a/FS25_ADSmartPickup/adSmartPickup.lua b/FS25_ADSmartPickup/adSmartPickup.lua new file mode 100644 index 0000000..ea7eada --- /dev/null +++ b/FS25_ADSmartPickup/adSmartPickup.lua @@ -0,0 +1,186 @@ +-- AD Smart Pickup +-- Gør AutoDrives mappe-rotation for pålæsning vare-bevidst: i stedet for +-- "næste markør i mappen" vælges den markør, hvis silo har den valgte vare — +-- og blandt dem den med MINDST i (så rester tømmes først og siloer frigives). +-- Findes ingen silo med varen, bruges AutoDrives originale rotation uændret. + +ADSmartPickup = {} + +-- Markøren er dér hvor trækkerens næse stopper; læssetriggeren ligger et +-- vogntog bagved. Siloer tættere end dette regnes som "markørens silo". +ADSmartPickup.MAX_MARKER_TO_TRIGGER_DISTANCE = 40 +-- Under dette niveau regnes siloen som tom (undgår ture efter støv-rester). +ADSmartPickup.MIN_USEFUL_LITERS = 1 +ADSmartPickup.LOG_PREFIX = "ADSmartPickup: " + +local isHooked = false + +-- Cross-mod adgang: FS25-mods er sandboxede; AutoDrives globals ligger +-- i den globale tabel FS25_AutoDrive (zip-navnet). +local function getAutoDriveEnv() + if FS25_AutoDrive ~= nil and FS25_AutoDrive.ADMultipleTargetsManager ~= nil then + return FS25_AutoDrive + end + return nil +end + +local function getFarmId(vehicle) + if vehicle.getOwnerFarmId ~= nil then + local farmId = vehicle:getOwnerFarmId() + if farmId ~= nil and farmId > 0 then + return farmId + end + end + if vehicle.spec_aiVehicle ~= nil and vehicle.spec_aiVehicle.startedFarmId ~= nil and vehicle.spec_aiVehicle.startedFarmId > 0 then + return vehicle.spec_aiVehicle.startedFarmId + end + return nil +end + +local function getWantedFillTypes(driver) + local wanted = {} + local selected = driver.ad.stateModule:getSelectedFillTypes() + if type(selected) == "table" then + for _, fillType in pairs(selected) do + table.insert(wanted, fillType) + end + end + if #wanted == 0 then + local fillType = driver.ad.stateModule:getFillType() + if fillType ~= nil then + table.insert(wanted, fillType) + end + end + return wanted +end + +local function getDistanceToStation(station, x, z) + local closest = math.huge + for _, loadTrigger in pairs(station.loadTriggers or {}) do + local node = loadTrigger.triggerNode + if node ~= nil and entityExists(node) then + local tx, _, tz = getWorldTranslation(node) + closest = math.min(closest, MathUtil.vector2Length(x - tx, z - tz)) + end + end + return closest +end + +local function getStationAtMarker(wayPoint, farmId) + local bestStation = nil + local bestDistance = ADSmartPickup.MAX_MARKER_TO_TRIGGER_DISTANCE + for _, station in pairs(g_currentMission.storageSystem:getLoadingStations()) do + if g_currentMission.accessHandler:canFarmAccess(farmId, station) then + local distance = getDistanceToStation(station, wayPoint.x, wayPoint.z) + if distance < bestDistance then + bestDistance = distance + bestStation = station + end + end + end + return bestStation +end + +-- Siloens EGET indhold. Udvidelses-siloer (isExtension) deler lager med naboer +-- inden for storageRadius, så stationens niveau er en nabo-sum — eget lager er +-- det tal spilleren ser på siloen og giver en præcis "mindst først"-rangering. +local function getOwnStorageLevel(station, fillTypes) + local placeable = station.owningPlaceable + local storages = placeable ~= nil and placeable.spec_silo ~= nil and placeable.spec_silo.storages or nil + if storages == nil then + return 0 + end + local total = 0 + for _, storage in pairs(storages) do + for _, fillType in ipairs(fillTypes) do + total = total + (storage:getFillLevel(fillType) or 0) + end + end + return total +end + +-- Returnerer niveau + om det er siloens eget lager (true) eller kun nabo-lagre (false). +local function getStationFillLevel(station, fillTypes, farmId) + local ownLevel = getOwnStorageLevel(station, fillTypes) + if ownLevel >= ADSmartPickup.MIN_USEFUL_LITERS then + return ownLevel, true + end + -- intet i eget lager: stationen kan stadig læsse fra nabo-lagre + local total = 0 + for _, fillType in ipairs(fillTypes) do + total = total + (station:getFillLevel(fillType, farmId) or 0) + end + return total, false +end + +-- Returnerer markerIndex for mappens mindst fyldte silo der har varen, ellers nil. +function ADSmartPickup.findLeastFilledPickup(adEnv, driver) + local firstMarker = driver.ad.stateModule:getFirstMarker() + if firstMarker == nil or firstMarker.group == nil or firstMarker.group == "All" then + return nil + end + local farmId = getFarmId(driver) + local fillTypes = getWantedFillTypes(driver) + if farmId == nil or #fillTypes == 0 then + return nil + end + + -- siloer med varen i EGET lager går altid forud for dem der kun kan låne fra naboer + local best = {[true] = {level = math.huge}, [false] = {level = math.huge}} + for _, marker in ipairs(adEnv.ADGraphManager:getMapMarkersInGroup(firstMarker.group)) do + local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id) + local station = wayPoint ~= nil and getStationAtMarker(wayPoint, farmId) or nil + if station ~= nil then + local level, isOwn = getStationFillLevel(station, fillTypes, farmId) + if level >= ADSmartPickup.MIN_USEFUL_LITERS and level < best[isOwn].level then + best[isOwn] = {level = level, marker = marker} + end + end + end + + local choice = best[true].marker ~= nil and best[true] or best[false] + if choice.marker == nil then + return nil + end + return choice.marker.markerIndex, choice.marker.name, choice.level +end + +local function installHook(adEnv) + local manager = adEnv.ADMultipleTargetsManager + local originalGetNextPickup = manager.getNextPickup + + manager.getNextPickup = function(self, driver, forcedSkip) + local ok, markerIndex, markerName, level = pcall(ADSmartPickup.findLeastFilledPickup, adEnv, driver) + if not ok then + Logging.warning(ADSmartPickup.LOG_PREFIX .. "fejl i silo-opslag, bruger AutoDrives rotation: %s", tostring(markerIndex)) + return originalGetNextPickup(self, driver, forcedSkip) + end + if markerIndex == nil then + return originalGetNextPickup(self, driver, forcedSkip) + end + + -- hold AutoDrives egen rotations-tilstand konsistent med vores valg + local groupName = driver.ad.stateModule:getFirstMarker().group + self.pickups[groupName] = self.pickups[groupName] or {} + self.pickups[groupName].lastTarget = markerIndex + self.pickups[groupName].lastVehicle = driver + + Logging.info(ADSmartPickup.LOG_PREFIX .. "'%s' -> %s (%.0f l)", tostring(driver:getName()), tostring(markerName), level) + return markerIndex + end + + Logging.info(ADSmartPickup.LOG_PREFIX .. "aktiv (mindst fyldte silo med varen vælges i mappe-rotation)") +end + +function ADSmartPickup:update(dt) + if isHooked then + return + end + local adEnv = getAutoDriveEnv() + if adEnv ~= nil and g_currentMission ~= nil and g_currentMission.storageSystem ~= nil then + installHook(adEnv) + isHooked = true + end +end + +addModEventListener(ADSmartPickup) diff --git a/FS25_ADSmartPickup/icon.dds b/FS25_ADSmartPickup/icon.dds new file mode 100644 index 0000000..217412e Binary files /dev/null and b/FS25_ADSmartPickup/icon.dds differ diff --git a/FS25_ADSmartPickup/modDesc.xml b/FS25_ADSmartPickup/modDesc.xml new file mode 100644 index 0000000..a052179 --- /dev/null +++ b/FS25_ADSmartPickup/modDesc.xml @@ -0,0 +1,16 @@ + + + masterdraco + 1.0.0.0 + + <en>AD Smart Pickup</en> + + + + + icon.dds + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f639817 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# FS25_ADSmartPickup + +AutoDrive-tillæg til Farming Simulator 25: når pålæsningsmål roteres i en AD-mappe, køres der +direkte til den silo i mappen, der har den valgte vare — **mindst fyldte først** — i stedet for +at prøve markørerne én efter én. + +- Wrapper `ADMultipleTargetsManager.getNextPickup` (via den globale `FS25_AutoDrive`-tabel); rører ikke AutoDrive-zip'en. +- Siloens eget lager rangeres før nabo-lån (udvidelses-siloer deler lager inden for `storageRadius`). +- Finder den ingen silo med varen, eller fejler et spil-API, bruges AutoDrives originale rotation. +- Kræver i AutoDrive: "Brug mapper" + "Rotér mål: pålæsning" på køretøjet. + +Test: `luajit tests/test_adSmartPickup.lua` · Byg: zip indholdet af `FS25_ADSmartPickup/` til `FS25_ADSmartPickup.zip`. diff --git a/tests/test_adSmartPickup.lua b/tests/test_adSmartPickup.lua new file mode 100644 index 0000000..d0b13ac --- /dev/null +++ b/tests/test_adSmartPickup.lua @@ -0,0 +1,138 @@ +-- Kør: luajit tests/test_adSmartPickup.lua (fra repo-roden) +-- Mock'er de få FS25-/AutoDrive-API'er moden rører og tester valg-logikken. + +local SUGARBEET, WHEAT = 10, 11 +local logLines = {} + +-- ---- FS25-mocks ---- +MathUtil = {vector2Length = function(x, z) return math.sqrt(x * x + z * z) end} +Logging = { + info = function(fmt, ...) table.insert(logLines, string.format(fmt, ...)) end, + warning = function(fmt, ...) table.insert(logLines, "WARN " .. string.format(fmt, ...)) end, +} +local nodePositions = {} +function entityExists(node) return nodePositions[node] ~= nil end +function getWorldTranslation(node) local p = nodePositions[node]; return p.x, 0, p.z end +local listener +function addModEventListener(l) listener = l end + +local function makeStation(node, x, z, levels, isBroken, ownLevels) + nodePositions[node] = {x = x, z = z} + local placeable = ownLevels ~= nil and {spec_silo = {storages = { + {getFillLevel = function(_, fillType) return ownLevels[fillType] or 0 end}, + }}} or nil + return { + owningPlaceable = placeable, + loadTriggers = {{triggerNode = node}}, + getFillLevel = function(_, fillType) + if isBroken then error("boom") end + return levels[fillType] or 0 + end, + } +end + +local stations = {} +g_currentMission = { + storageSystem = {getLoadingStations = function() return stations end}, + accessHandler = {canFarmAccess = function() return true end}, +} + +-- ---- AutoDrive-mocks ---- +local markers, wayPoints = {}, {} +local function addMarker(index, name, group, x, z) + wayPoints[index] = {x = x, z = z} + table.insert(markers, {id = index, markerIndex = index, name = name, group = group}) +end + +local function resetWorld() + stations, markers, wayPoints, nodePositions, logLines = {}, {}, {}, {}, {} + FS25_AutoDrive.ADMultipleTargetsManager.pickups = {} +end + +FS25_AutoDrive = { + ADMultipleTargetsManager = {pickups = {}, getNextPickup = function() return "ORIGINAL" end}, + ADGraphManager = { + getMapMarkersInGroup = function(_, group) + local result = {} + for _, m in ipairs(markers) do if m.group == group then table.insert(result, m) end end + return result + end, + getWayPointById = function(_, id) return wayPoints[id] end, + }, +} + +local function makeDriver(firstMarker, fillTypes) + return { + getName = function() return "Testtraktor" end, + getOwnerFarmId = function() return 1 end, + ad = {stateModule = { + getFirstMarker = function() return firstMarker end, + getSelectedFillTypes = function() return fillTypes end, + getFillType = function() return fillTypes[1] end, + }}, + } +end + +dofile("FS25_ADSmartPickup/adSmartPickup.lua") +listener:update(16) -- installerer hook'en +local manager = FS25_AutoDrive.ADMultipleTargetsManager + +local failures = 0 +local function check(name, actual, expected) + if actual == expected then + print("OK " .. name) + else + failures = failures + 1 + print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual))) + end +end + +-- 1: vælger mindst fyldte silo med varen; springer tomme og forkert vare over +resetWorld() +addMarker(1, "Silo 1", "Siloer", 0, 0); table.insert(stations, makeStation(101, 0, 15, {[WHEAT] = 900000})) +addMarker(2, "Silo 2", "Siloer", 500, 0); table.insert(stations, makeStation(102, 500, 15, {[SUGARBEET] = 800000})) +addMarker(3, "Silo 3", "Siloer", 1000, 0); table.insert(stations, makeStation(103, 1000, 15, {[SUGARBEET] = 12000})) +addMarker(4, "Silo 4", "Siloer", 1500, 0); table.insert(stations, makeStation(104, 1500, 15, {[SUGARBEET] = 0})) +addMarker(5, "Fabrik", "Andet", 1000, 5); -- anden mappe, skal ignoreres +check("mindst fyldte med varen vælges", manager:getNextPickup(makeDriver(markers[1], {SUGARBEET}), false), 3) +check("AD-rotationstilstand opdateret", manager.pickups["Siloer"].lastTarget, 3) + +-- 2: ingen silo har varen -> AutoDrives originale rotation +check("fallback når varen ikke findes", manager:getNextPickup(makeDriver(markers[1], {99}), false), "ORIGINAL") + +-- 3: markør uden silo i nærheden ignoreres +resetWorld() +addMarker(1, "Langt væk", "Siloer", 0, 0); table.insert(stations, makeStation(101, 0, 200, {[SUGARBEET] = 5})) +addMarker(2, "Silo 2", "Siloer", 500, 0); table.insert(stations, makeStation(102, 500, 15, {[SUGARBEET] = 700})) +check("markør uden nær silo ignoreres", manager:getNextPickup(makeDriver(markers[1], {SUGARBEET}), false), 2) + +-- 4: flere valgte varer lægges sammen +resetWorld() +addMarker(1, "Silo 1", "Siloer", 0, 0); table.insert(stations, makeStation(101, 0, 15, {[SUGARBEET] = 100, [WHEAT] = 5000})) +addMarker(2, "Silo 2", "Siloer", 500, 0); table.insert(stations, makeStation(102, 500, 15, {[SUGARBEET] = 3000})) +check("flere varetyper summeres", manager:getNextPickup(makeDriver(markers[1], {SUGARBEET, WHEAT}), false), 2) + +-- 4b: klynge med delt lager — eget indhold slår nabo-sum, også når nabo-summen er mindre +resetWorld() +addMarker(1, "Nabo", "Siloer", 0, 0); table.insert(stations, makeStation(101, 0, 15, {[SUGARBEET] = 800}, false, {})) +addMarker(2, "Ejer stor", "Siloer", 14, 0); table.insert(stations, makeStation(102, 14, 15, {[SUGARBEET] = 900000}, false, {[SUGARBEET] = 500000})) +addMarker(3, "Ejer lille", "Siloer", 28, 0);table.insert(stations, makeStation(103, 28, 15, {[SUGARBEET] = 900000}, false, {[SUGARBEET] = 20000})) +check("eget lager foran nabo-lån, mindst først", manager:getNextPickup(makeDriver(markers[1], {SUGARBEET}), false), 3) + +-- 4c: kun nabo-lån muligt -> bruges stadig +resetWorld() +addMarker(1, "Nabo", "Siloer", 0, 0); table.insert(stations, makeStation(101, 0, 15, {[SUGARBEET] = 800}, false, {})) +check("nabo-lån bruges når ingen ejer varen", manager:getNextPickup(makeDriver(markers[1], {SUGARBEET}), false), 1) + +-- 5: fejl i spil-API -> fallback + advarsel, aldrig crash +resetWorld() +addMarker(1, "Silo 1", "Siloer", 0, 0); table.insert(stations, makeStation(101, 0, 15, {}, true)) +check("fejl giver fallback", manager:getNextPickup(makeDriver(markers[1], {SUGARBEET}), false), "ORIGINAL") +check("fejl logges som advarsel", logLines[#logLines]:sub(1, 4), "WARN") + +-- 6: gruppen "All" og manglende første markør røres ikke +check("gruppe All -> fallback", manager:getNextPickup(makeDriver({group = "All", markerIndex = 1}, {SUGARBEET}), false), "ORIGINAL") +check("ingen første markør -> fallback", manager:getNextPickup(makeDriver(nil, {SUGARBEET}), false), "ORIGINAL") + +print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) +os.exit(failures == 0 and 0 or 1)