diff --git a/FS25_ADSmartPickup/adSmartPickup.lua b/FS25_ADSmartPickup/adSmartPickup.lua index ea7eada..e76e227 100644 --- a/FS25_ADSmartPickup/adSmartPickup.lua +++ b/FS25_ADSmartPickup/adSmartPickup.lua @@ -3,6 +3,10 @@ -- "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. +-- +-- Behovsstyret forsyning: er der valgt FLERE varer i AutoDrive, ses de som +-- ingredienser til aflæsningsmålet (fx en TMR-blander). Pr. tur hentes den +-- ingrediens målet mangler mest, fra en silo hvor som helst på kortet. ADSmartPickup = {} @@ -13,7 +17,13 @@ ADSmartPickup.MAX_MARKER_TO_TRIGGER_DISTANCE = 40 ADSmartPickup.MIN_USEFUL_LITERS = 1 ADSmartPickup.LOG_PREFIX = "ADSmartPickup: " +local LOAD_TRIGGER_NODE_FIELDS = {"triggerNode"} +local UNLOAD_TRIGGER_NODE_FIELDS = {"exactFillRootNode", "triggerNode", "aiNode"} + local isHooked = false +-- køretøj -> {ingredients, narrowedTo}. AutoDrive roterer selv vare ved triggeren +-- når flere er valgt, så valget indsnævres til én pr. tur; listen huskes her. +local supplyState = setmetatable({}, {__mode = "k"}) -- Cross-mod adgang: FS25-mods er sandboxede; AutoDrives globals ligger -- i den globale tabel FS25_AutoDrive (zip-navnet). @@ -54,24 +64,26 @@ local function getWantedFillTypes(driver) return wanted end -local function getDistanceToStation(station, x, z) +local function getDistanceToTriggers(triggers, nodeFields, 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)) + for _, trigger in pairs(triggers or {}) do + for _, field in ipairs(nodeFields) do + local node = trigger[field] + if node ~= nil and entityExists(node) then + local tx, _, tz = getWorldTranslation(node) + closest = math.min(closest, MathUtil.vector2Length(x - tx, z - tz)) + end end end return closest end -local function getStationAtMarker(wayPoint, farmId) +local function getClosestStation(candidateStations, triggersField, nodeFields, wayPoint, farmId) local bestStation = nil local bestDistance = ADSmartPickup.MAX_MARKER_TO_TRIGGER_DISTANCE - for _, station in pairs(g_currentMission.storageSystem:getLoadingStations()) do + for _, station in pairs(candidateStations) do if g_currentMission.accessHandler:canFarmAccess(farmId, station) then - local distance = getDistanceToStation(station, wayPoint.x, wayPoint.z) + local distance = getDistanceToTriggers(station[triggersField], nodeFields, wayPoint.x, wayPoint.z) if distance < bestDistance then bestDistance = distance bestStation = station @@ -81,6 +93,14 @@ local function getStationAtMarker(wayPoint, farmId) return bestStation end +local function getLoadStationAtMarker(wayPoint, farmId) + return getClosestStation(g_currentMission.storageSystem:getLoadingStations(), "loadTriggers", LOAD_TRIGGER_NODE_FIELDS, wayPoint, farmId) +end + +local function getUnloadStationAtMarker(wayPoint, farmId) + return getClosestStation(g_currentMission.storageSystem:getUnloadingStations(), "unloadTriggers", UNLOAD_TRIGGER_NODE_FIELDS, wayPoint, farmId) +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. @@ -113,7 +133,45 @@ local function getStationFillLevel(station, fillTypes, farmId) return total, false end --- Returnerer markerIndex for mappens mindst fyldte silo der har varen, ellers nil. +-- Parrer markører med deres læssestation (markører uden station udelades). +-- excludedPlaceable: aflæsningsmålets egen placeable må ikke være kilde. +local function collectMarkerStations(adEnv, candidateMarkers, farmId, excludedPlaceable) + local markerStations = {} + for _, marker in pairs(candidateMarkers) do + local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id) + local station = wayPoint ~= nil and getLoadStationAtMarker(wayPoint, farmId) or nil + local isExcluded = station ~= nil and excludedPlaceable ~= nil and station.owningPlaceable == excludedPlaceable + if station ~= nil and not isExcluded then + table.insert(markerStations, {marker = marker, station = station}) + end + end + return markerStations +end + +-- Mindst fyldte silo der har varen; siloer med varen i EGET lager går altid +-- forud for dem der kun kan låne fra naboer. Returnerer et valg eller nil. +local function findLeastFilled(markerStations, fillTypes, farmId) + local best = {[true] = {level = math.huge}, [false] = {level = math.huge}} + for _, entry in ipairs(markerStations) do + local level, isOwn = getStationFillLevel(entry.station, fillTypes, farmId) + if level >= ADSmartPickup.MIN_USEFUL_LITERS and level < best[isOwn].level then + best[isOwn] = {level = level, marker = entry.marker} + end + end + + local choice = best[true].marker ~= nil and best[true] or best[false] + if choice.marker == nil then + return nil + end + return { + markerIndex = choice.marker.markerIndex, + markerName = choice.marker.name, + group = choice.marker.group, + level = choice.level, + } +end + +-- Mappe-logik: mappens mindst fyldte silo der har den/de valgte varer, 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 @@ -124,25 +182,92 @@ function ADSmartPickup.findLeastFilledPickup(adEnv, driver) if farmId == nil or #fillTypes == 0 then return nil end + local groupMarkers = adEnv.ADGraphManager:getMapMarkersInGroup(firstMarker.group) + return findLeastFilled(collectMarkerStations(adEnv, groupMarkers, farmId, nil), fillTypes, farmId) +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} +-- Niveau + kapacitet pr. ingrediens i aflæsningsmålets lager. +local function getTargetNeeds(station, ingredients, farmId) + local needs = {} + for _, fillType in ipairs(ingredients) do + if station.getIsFillTypeSupported == nil or station:getIsFillTypeSupported(fillType) then + local level, freeSpace = 0, 0 + for _, storage in pairs(station.targetStorages or {}) do + level = level + (storage:getFillLevel(fillType) or 0) + freeSpace = freeSpace + (storage:getFreeCapacity(fillType) or 0) end + if level + freeSpace <= 0 and station.getFreeCapacity ~= nil then + -- ukendt lagertype: brug stationens egen opgørelse af fri plads + level, freeSpace = 0, station:getFreeCapacity(fillType, farmId) or 0 + end + table.insert(needs, {fillType = fillType, level = level, capacity = level + freeSpace}) end end + return needs +end - local choice = best[true].marker ~= nil and best[true] or best[false] - if choice.marker == nil then +-- Behovsstyret: den ingrediens aflæsningsmålet mangler mest, som også findes i en silo. +function ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients) + local secondMarker = driver.ad.stateModule:getSecondMarker() + local farmId = getFarmId(driver) + if secondMarker == nil or farmId == nil then return nil end - return choice.marker.markerIndex, choice.marker.name, choice.level + local targetWayPoint = adEnv.ADGraphManager:getWayPointById(secondMarker.id) + local target = targetWayPoint ~= nil and getUnloadStationAtMarker(targetWayPoint, farmId) or nil + if target == nil then + return nil + end + + local allMarkers = adEnv.ADGraphManager:getMapMarkers() + local markerStations = collectMarkerStations(adEnv, allMarkers, farmId, target.owningPlaceable) + for _, need in ipairs(ADSupplyPlanner.rankNeeds(getTargetNeeds(target, ingredients, farmId))) do + local choice = findLeastFilled(markerStations, {need.fillType}, farmId) + if choice ~= nil then + return { + markerIndex = choice.markerIndex, + markerName = choice.markerName, + group = choice.group, + level = choice.level, + fillType = need.fillType, + need = need, + } + end + end + return nil +end + +local function applySupplyChoice(driver, ingredients, choice) + local stateModule = driver.ad.stateModule + stateModule:setFillType(choice.fillType) + stateModule.selectedFillTypes = {choice.fillType} + stateModule:raiseDirtyFlag() + supplyState[driver] = {ingredients = ingredients, narrowedTo = choice.fillType} +end + +-- Samlet valg: behovsstyret når flere varer er valgt, ellers mappe-logik. nil = AutoDrives original. +function ADSmartPickup.choosePickup(adEnv, driver) + local selected = driver.ad.stateModule:getSelectedFillTypes() + local ingredients = ADSupplyPlanner.resolveIngredients(supplyState[driver], selected) + if ingredients == nil then + supplyState[driver] = nil + return ADSmartPickup.findLeastFilledPickup(adEnv, driver) + end + + local choice = ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients) + if choice == nil then + return ADSmartPickup.findLeastFilledPickup(adEnv, driver) + end + applySupplyChoice(driver, ingredients, choice) + return choice +end + +local function describeChoice(choice) + if choice.fillType == nil then + return string.format("%.0f l", choice.level) + end + local fillTypeName = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(choice.fillType) or tostring(choice.fillType) + return string.format("%s: kilde %.0f l, målet har %.0f/%.0f l", tostring(fillTypeName), choice.level, choice.need.level, choice.need.capacity) end local function installHook(adEnv) @@ -150,36 +275,57 @@ local function installHook(adEnv) local originalGetNextPickup = manager.getNextPickup manager.getNextPickup = function(self, driver, forcedSkip) - local ok, markerIndex, markerName, level = pcall(ADSmartPickup.findLeastFilledPickup, adEnv, driver) + local ok, choice = pcall(ADSmartPickup.choosePickup, adEnv, driver) if not ok then - Logging.warning(ADSmartPickup.LOG_PREFIX .. "fejl i silo-opslag, bruger AutoDrives rotation: %s", tostring(markerIndex)) + Logging.warning(ADSmartPickup.LOG_PREFIX .. "fejl i silo-opslag, bruger AutoDrives rotation: %s", tostring(choice)) return originalGetNextPickup(self, driver, forcedSkip) end - if markerIndex == nil then + if choice == 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 + self.pickups[choice.group] = self.pickups[choice.group] or {} + self.pickups[choice.group].lastTarget = choice.markerIndex + self.pickups[choice.group].lastVehicle = driver - Logging.info(ADSmartPickup.LOG_PREFIX .. "'%s' -> %s (%.0f l)", tostring(driver:getName()), tostring(markerName), level) - return markerIndex + Logging.info(ADSmartPickup.LOG_PREFIX .. "'%s' -> %s (%s)", tostring(driver:getName()), tostring(choice.markerName), describeChoice(choice)) + return choice.markerIndex end - Logging.info(ADSmartPickup.LOG_PREFIX .. "aktiv (mindst fyldte silo med varen vælges i mappe-rotation)") + Logging.info(ADSmartPickup.LOG_PREFIX .. "aktiv (mindst fyldte silo med varen; flere valgte varer = behovsstyret forsyning)") +end + +-- Når AutoDrive stopper, får køretøjet hele ingredienslisten tilbage i varevalget +-- (AutoDrive gemmer valget i savegame, så listen overlever også en genindlæsning). +local function restoreStoppedVehicles() + for vehicle, state in pairs(supplyState) do + local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil + if stateModule == nil or not stateModule:isActive() then + supplyState[vehicle] = nil + local selected = stateModule ~= nil and stateModule:getSelectedFillTypes() or nil + if type(selected) == "table" and #selected == 1 and selected[1] == state.narrowedTo then + stateModule.selectedFillTypes = ADSupplyPlanner.copyList(state.ingredients) + stateModule:raiseDirtyFlag() + end + end + end end function ADSmartPickup:update(dt) - if isHooked then + if not isHooked then + local adEnv = getAutoDriveEnv() + if adEnv ~= nil and g_currentMission ~= nil and g_currentMission.storageSystem ~= nil then + installHook(adEnv) + isHooked = true + end return end - local adEnv = getAutoDriveEnv() - if adEnv ~= nil and g_currentMission ~= nil and g_currentMission.storageSystem ~= nil then - installHook(adEnv) - isHooked = true + + local ok, err = pcall(restoreStoppedVehicles) + if not ok then + Logging.warning(ADSmartPickup.LOG_PREFIX .. "kunne ikke gendanne varevalg: %s", tostring(err)) + supplyState = setmetatable({}, {__mode = "k"}) end end diff --git a/tests/test_adSmartPickup.lua b/tests/test_adSmartPickup.lua index d0b13ac..d67b0f3 100644 --- a/tests/test_adSmartPickup.lua +++ b/tests/test_adSmartPickup.lua @@ -2,6 +2,7 @@ -- Mock'er de få FS25-/AutoDrive-API'er moden rører og tester valg-logikken. local SUGARBEET, WHEAT = 10, 11 +local HAY, GRASS, SILAGE = 20, 21, 22 local logLines = {} -- ---- FS25-mocks ---- @@ -31,9 +32,30 @@ local function makeStation(node, x, z, levels, isBroken, ownLevels) } end -local stations = {} +-- Aflæsningsmål (fx TMR-blander): ét lager med niveau + kapacitet pr. vare. +local function makeUnloadStation(node, x, z, levels, capacities, placeable, isBroken) + nodePositions[node] = {x = x, z = z} + local storage = { + getFillLevel = function(_, fillType) + if isBroken then error("boom") end + return levels[fillType] or 0 + end, + getFreeCapacity = function(_, fillType) return (capacities[fillType] or 0) - (levels[fillType] or 0) end, + } + return { + owningPlaceable = placeable, + unloadTriggers = {{exactFillRootNode = node}}, + targetStorages = {[storage] = storage}, + getIsFillTypeSupported = function(_, fillType) return capacities[fillType] ~= nil end, + } +end + +local stations, unloadStations = {}, {} g_currentMission = { - storageSystem = {getLoadingStations = function() return stations end}, + storageSystem = { + getLoadingStations = function() return stations end, + getUnloadingStations = function() return unloadStations end, + }, accessHandler = {canFarmAccess = function() return true end}, } @@ -45,7 +67,7 @@ local function addMarker(index, name, group, x, z) end local function resetWorld() - stations, markers, wayPoints, nodePositions, logLines = {}, {}, {}, {}, {} + stations, unloadStations, markers, wayPoints, nodePositions, logLines = {}, {}, {}, {}, {}, {} FS25_AutoDrive.ADMultipleTargetsManager.pickups = {} end @@ -58,6 +80,7 @@ FS25_AutoDrive = { return result end, getWayPointById = function(_, id) return wayPoints[id] end, + getMapMarkers = function() return markers end, }, } @@ -67,12 +90,31 @@ local function makeDriver(firstMarker, fillTypes) getOwnerFarmId = function() return 1 end, ad = {stateModule = { getFirstMarker = function() return firstMarker end, + getSecondMarker = function() return nil end, getSelectedFillTypes = function() return fillTypes end, getFillType = function() return fillTypes[1] end, }}, } end +-- Muterbar stateModule-mock: behovsstyringen skifter vare og indsnævrer AD's valg. +local function makeSupplyDriver(firstMarker, secondMarker, fillTypes) + local stateModule = {selectedFillTypes = fillTypes, fillType = fillTypes[1], active = true, dirtyCount = 0} + stateModule.getFirstMarker = function() return firstMarker end + stateModule.getSecondMarker = function() return secondMarker end + stateModule.getSelectedFillTypes = function(self) return self.selectedFillTypes end + stateModule.getFillType = function(self) return self.fillType end + stateModule.setFillType = function(self, fillType) self.fillType = fillType end + stateModule.raiseDirtyFlag = function(self) self.dirtyCount = self.dirtyCount + 1 end + stateModule.isActive = function(self) return self.active end + return { + getName = function() return "Fodertraktor" end, + getOwnerFarmId = function() return 1 end, + ad = {stateModule = stateModule}, + } +end + +dofile("FS25_ADSmartPickup/adSupplyPlanner.lua") dofile("FS25_ADSmartPickup/adSmartPickup.lua") listener:update(16) -- installerer hook'en local manager = FS25_AutoDrive.ADMultipleTargetsManager @@ -134,5 +176,83 @@ check("fejl logges som advarsel", logLines[#logLines]:sub(1, 4), "WARN") 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") +-- ==== Behovsstyret forsyning (flere varer valgt + aflæsningsmål med lager) ==== +local BLANDER = {name = "TMR-blander placeable"} +local FULL = {[HAY] = 100000, [GRASS] = 100000, [SILAGE] = 100000} +local blanderLevels + +local function buildFeedWorld() + resetWorld() + blanderLevels = {[HAY] = 80000, [GRASS] = 20000, [SILAGE] = 50000} + addMarker(1, "Hølade", "Lader", 0, 0); table.insert(stations, makeStation(101, 0, 15, {[HAY] = 50000})) + addMarker(2, "Græssilo stor", "Siloer", 500, 0); table.insert(stations, makeStation(102, 500, 15, {[GRASS] = 300000})) + addMarker(3, "Græssilo lille", "Andet", 1000, 0);table.insert(stations, makeStation(103, 1000, 15, {[GRASS] = 40000})) + addMarker(4, "Ensilagesilo", "Siloer", 1500, 0); table.insert(stations, makeStation(104, 1500, 15, {[SILAGE] = 200000})) + addMarker(10, "TMR-blander", "Produktion", 5000, 0) + table.insert(unloadStations, makeUnloadStation(110, 5000, 10, blanderLevels, FULL, BLANDER)) +end +local function markerByIndex(index) + for _, m in ipairs(markers) do if m.markerIndex == index then return m end end +end + +-- S1: mest trængende ingrediens vælges, kilde findes på tværs af mapper, AD's vare skiftes +buildFeedWorld() +local feeder = makeSupplyDriver(markerByIndex(1), markerByIndex(10), {HAY, GRASS, SILAGE}) +check("S1 mest trængende vare, mindst fyldte kilde", manager:getNextPickup(feeder, false), 3) +check("S1 AD's vare skiftet til græs", feeder.ad.stateModule.fillType, GRASS) +check("S1 AD's valg indsnævret til én vare", #feeder.ad.stateModule.selectedFillTypes, 1) +check("S1 ændring synkroniseres (dirty-flag)", feeder.ad.stateModule.dirtyCount > 0, true) +check("S1 rotationstilstand følger valgt markørs mappe", manager.pickups["Andet"].lastTarget, 3) + +-- S2: næste tur — gemt liste genbruges; græs er nu fuldt og springes over -> ensilage +blanderLevels[GRASS] = 99500 +check("S2 fuld ingrediens springes over", manager:getNextPickup(feeder, false), 4) +check("S2 vare skiftet til ensilage", feeder.ad.stateModule.fillType, SILAGE) + +-- S3: ingrediens uden kilde springes over -> næste i behovs-rækkefølgen +blanderLevels[SILAGE] = 10000 +stations[4] = makeStation(104, 1500, 15, {[SILAGE] = 0}) +check("S3 ingen kilde -> næste ingrediens", manager:getNextPickup(feeder, false), 1) +check("S3 vare skiftet til hø", feeder.ad.stateModule.fillType, HAY) + +-- S4: AD stopper -> hele ingredienslisten gendannes i AD's varevalg +feeder.ad.stateModule.active = false +listener:update(16) +check("S4 valg gendannet ved stop", #feeder.ad.stateModule.selectedFillTypes, 3) + +-- S5: aflæsningsmålets egen læssestation bruges ikke som kilde +buildFeedWorld() +addMarker(11, "Blander udtag", "Produktion", 5030, 0) +table.insert(stations, makeStation(111, 5030, 15, {[GRASS] = 10}, false, nil)) +stations[#stations].owningPlaceable = BLANDER +feeder = makeSupplyDriver(markerByIndex(1), markerByIndex(10), {HAY, GRASS}) +check("S5 målets egen station udelukkes som kilde", manager:getNextPickup(feeder, false), 3) + +-- S6: brugeren vælger selv en anden enkelt vare -> behovsstyring fra (mappe-logik) +feeder.ad.stateModule.selectedFillTypes = {HAY} +feeder.ad.stateModule.fillType = HAY +check("S6 brugerens enkelt-valg -> mappe-logik", manager:getNextPickup(feeder, false), 1) +check("S6 vare røres ikke", feeder.ad.stateModule.fillType, HAY) + +-- S7: intet behov (alt fuldt) -> mappe-logik, valg indsnævres ikke +buildFeedWorld() +blanderLevels[HAY], blanderLevels[GRASS], blanderLevels[SILAGE] = 99900, 99900, 99900 +feeder = makeSupplyDriver(markerByIndex(1), markerByIndex(10), {HAY, GRASS, SILAGE}) +check("S7 intet behov -> mappe-logik", manager:getNextPickup(feeder, false), 1) +check("S7 valg ikke indsnævret", #feeder.ad.stateModule.selectedFillTypes, 3) + +-- S8: ingen aflæsningsstation ved målet -> mappe-logik +buildFeedWorld() +unloadStations = {} +feeder = makeSupplyDriver(markerByIndex(2), markerByIndex(10), {GRASS, SILAGE}) +check("S8 ingen station ved mål -> mappe-logik", manager:getNextPickup(feeder, false), 4) + +-- S9: fejl i målets lager-API -> AutoDrives original + advarsel +buildFeedWorld() +unloadStations = {makeUnloadStation(110, 5000, 10, blanderLevels, FULL, BLANDER, true)} +feeder = makeSupplyDriver(markerByIndex(1), markerByIndex(10), {HAY, GRASS}) +check("S9 fejl giver fallback", manager:getNextPickup(feeder, false), "ORIGINAL") +check("S9 fejl logges som advarsel", logLines[#logLines]:sub(1, 4), "WARN") + print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) os.exit(failures == 0 and 0 or 1)