diff --git a/FS25_ADSmartPickup/adSmartPickup.lua b/FS25_ADSmartPickup/adSmartPickup.lua index 890e894..7923361 100644 --- a/FS25_ADSmartPickup/adSmartPickup.lua +++ b/FS25_ADSmartPickup/adSmartPickup.lua @@ -250,21 +250,81 @@ function ADSmartPickup.findLeastFilledPickup(adEnv, driver) return findBestSource(collectMarkerStations(adEnv, groupMarkers, farmId, nil), fillTypes, farmId) end +local function getProductionPoint(station) + local placeable = station.owningPlaceable + if placeable ~= nil and placeable.spec_productionPoint ~= nil then + return placeable.spec_productionPoint.productionPoint + end + return nil +end + -- Lagrene bag aflæsningsstationen; produktionspunkter uden targetStorages læses direkte. local function getTargetStorages(station) if station.targetStorages ~= nil and next(station.targetStorages) ~= nil then return station.targetStorages end - local placeable = station.owningPlaceable - local productionPoint = placeable ~= nil and placeable.spec_productionPoint ~= nil and placeable.spec_productionPoint.productionPoint or nil + local productionPoint = getProductionPoint(station) if productionPoint ~= nil and productionPoint.storage ~= nil then return {productionPoint.storage} end return {} end --- Niveau + kapacitet pr. ingrediens i aflæsningsmålets lager. -local function getTargetNeeds(station, ingredients, farmId) +local function isProductionEnabled(productionPoint, production) + if productionPoint.getIsProductionEnabled ~= nil then + return productionPoint:getIsProductionEnabled(production.id) == true + end + for _, active in ipairs(productionPoint.activeProductions or {}) do + if active == production then + return true + end + end + return false +end + +-- Fabriksstyret: {[fillType] = liter/min} som målets ønskede produktioner bruger, +-- begrænset til det vognen kan bære. Tom tabel = intet produktionspunkt/intet ønsket. +local function getProductionDemand(adEnv, station, driver) + local productionPoint = getProductionPoint(station) + if productionPoint == nil or productionPoint.productions == nil then + return {} + end + local canCarry = {} + for _, fillType in ipairs(adEnv.AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically(driver) or {}) do + canCarry[fillType] = true + end + local missingInputs = ProductionPoint ~= nil and ProductionPoint.PROD_STATUS ~= nil and ProductionPoint.PROD_STATUS.MISSING_INPUTS or nil + local function isWanted(production) + return ADSupplyPlanner.isProductionWanted(isProductionEnabled(productionPoint, production), production.status, missingInputs) + end + return ADSupplyPlanner.collectProductionDemand(productionPoint.productions, isWanted, canCarry) +end + +-- Fabrikkens inputs som sorteret liste (stabil rækkefølge -> stabil log/rangering). +local function getDemandFillTypes(demand) + local fillTypes = {} + for fillType in pairs(demand) do + table.insert(fillTypes, fillType) + end + table.sort(fillTypes) + return fillTypes +end + +-- Fabrik som mål uden ønskede produktioner: sig det én gang, så en stille +-- tilbagefald til brugerens valg ikke ligner en fejl i moden. +local factoryFallbackLogged = setmetatable({}, {__mode = "k"}) +local function logFactoryFallbackOnce(driver, station) + local placeable = station.owningPlaceable + local name = placeable ~= nil and placeable.getName ~= nil and placeable:getName() or "fabrik" + local message = string.format("'%s': %s har ingen ønskede produktioner (tændte eller auto-slukkede pga. mangel) med varer vognen kan bære; bruger dit varevalg", tostring(driver:getName()), tostring(name)) + if factoryFallbackLogged[driver] ~= message then + factoryFallbackLogged[driver] = message + Logging.info(ADSmartPickup.LOG_PREFIX .. "%s", message) + end +end + +-- Niveau + kapacitet pr. ingrediens i aflæsningsmålets lager; demand giver forbrug/min. +local function getTargetNeeds(station, ingredients, farmId, demand) local needs = {} local storages = getTargetStorages(station) for _, fillType in ipairs(ingredients) do @@ -278,7 +338,7 @@ local function getTargetNeeds(station, ingredients, farmId) -- 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}) + table.insert(needs, {fillType = fillType, level = level, capacity = level + freeSpace, litersPerMinute = demand[fillType]}) end end return needs @@ -297,9 +357,19 @@ function ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients) return nil end + -- Er målet en fabrik med ønskede produktioner, følger listen fabrikken — + -- brugerens multi-valg er kun tændknappen (og gendannes ved stop). + local demand = getProductionDemand(adEnv, target, driver) + if next(demand) ~= nil then + ingredients = getDemandFillTypes(demand) + factoryFallbackLogged[driver] = nil + elseif getProductionPoint(target) ~= nil then + logFactoryFallbackOnce(driver, target) + 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 + for _, need in ipairs(ADSupplyPlanner.rankNeeds(getTargetNeeds(target, ingredients, farmId, demand))) do local choice = findBestSource(markerStations, {need.fillType}, farmId) if choice ~= nil then return { @@ -351,7 +421,11 @@ local function describeChoice(choice) return string.format("%.0f l, %s", choice.level, source) end local fillTypeName = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(choice.fillType) or tostring(choice.fillType) - return string.format("%s: kilde %.0f l, %s, målet har %.0f/%.0f l", tostring(fillTypeName), choice.level, source, choice.need.level, choice.need.capacity) + local urgency = "" + if choice.need.minutesToEmpty ~= nil then + urgency = string.format(", fabrikken bruger %.0f l/min = %.0f min til tom", choice.need.litersPerMinute, choice.need.minutesToEmpty) + end + return string.format("%s: kilde %.0f l, %s, målet har %.0f/%.0f l%s", tostring(fillTypeName), choice.level, source, choice.need.level, choice.need.capacity, urgency) end local function logChoiceOnce(driver, choice) diff --git a/FS25_ADSmartPickup/adSupplyPlanner.lua b/FS25_ADSmartPickup/adSupplyPlanner.lua index e3552e9..1c44d48 100644 --- a/FS25_ADSmartPickup/adSupplyPlanner.lua +++ b/FS25_ADSmartPickup/adSupplyPlanner.lua @@ -48,29 +48,73 @@ function ADSupplyPlanner.copyList(list) return copy end +-- Ønsker en produktion input? Tændt, eller slukket af kortet fordi den MANGLEDE +-- input (Dakotas ExtendedProductionPoint slår den fra automatisk, men lader +-- status stå på MISSING_INPUTS). Manuelt slukket = status INACTIVE -> nej. +function ADSupplyPlanner.isProductionWanted(isEnabled, status, missingInputsStatus) + if isEnabled then + return true + end + return status ~= nil and status == missingInputsStatus +end + +-- productions: produktionspunktets liste ({id, cyclesPerMinute, inputs = {{type, amount}}}). +-- isWanted(production) afgør om den tæller; canCarry[fillType] = det vognen kan bære. +-- Returnerer {[fillType] = liter pr. minut} summeret over ønskede produktioner. +function ADSupplyPlanner.collectProductionDemand(productions, isWanted, canCarry) + local demand = {} + for _, production in ipairs(productions) do + if isWanted(production) then + local cyclesPerMinute = production.cyclesPerMinute or 0 + for _, input in ipairs(production.inputs or {}) do + if canCarry[input.type] then + demand[input.type] = (demand[input.type] or 0) + (input.amount or 0) * cyclesPerMinute + end + end + end + end + return demand +end + +-- Minutter før målets lager af varen er tomt ved nuværende forbrug; nil uden forbrug. +function ADSupplyPlanner.minutesToEmpty(level, litersPerMinute) + if litersPerMinute == nil or litersPerMinute <= 0 then + return nil + end + return level / litersPerMinute +end + -- Hvor meget trænger målet til denne ingrediens? Højere = hentes først. -- Alle tal er liter i aflæsningsmålets lager. Standard: relativ tomhed (0..1), --- så en lille næsten-tom tank vinder over en stor halvfuld. -function ADSupplyPlanner.scoreNeed(level, capacity, freeSpace) +-- så en lille næsten-tom tank vinder over en stor halvfuld. Kendes forbruget +-- (fabriksstyret), tæller i stedet tid til tom: score i ]1, 2], så varer med +-- forbrug altid går forud for varer uden, og kortest tid vinder. +function ADSupplyPlanner.scoreNeed(level, capacity, freeSpace, minutesToEmpty) + if minutesToEmpty ~= nil then + return 1 + 1 / (1 + minutesToEmpty) + end if capacity <= 0 then return 0 end return freeSpace / capacity end --- needs: {{fillType, level, capacity}, ...}. Returnerer NY liste, mest trængende først; --- ingredienser uden plads i målet er sorteret fra. +-- needs: {{fillType, level, capacity[, litersPerMinute]}, ...}. Returnerer NY liste, +-- mest trængende først; ingredienser uden plads i målet er sorteret fra. function ADSupplyPlanner.rankNeeds(needs) local ranked = {} for _, need in ipairs(needs) do local freeSpace = need.capacity - need.level if freeSpace >= ADSupplyPlanner.MIN_FREE_LITERS then + local minutesToEmpty = ADSupplyPlanner.minutesToEmpty(need.level, need.litersPerMinute) table.insert(ranked, { fillType = need.fillType, level = need.level, capacity = need.capacity, freeSpace = freeSpace, - score = ADSupplyPlanner.scoreNeed(need.level, need.capacity, freeSpace), + litersPerMinute = need.litersPerMinute, + minutesToEmpty = minutesToEmpty, + score = ADSupplyPlanner.scoreNeed(need.level, need.capacity, freeSpace, minutesToEmpty), }) end end diff --git a/FS25_ADSmartPickup/modDesc.xml b/FS25_ADSmartPickup/modDesc.xml index deebe28..9de90c1 100644 --- a/FS25_ADSmartPickup/modDesc.xml +++ b/FS25_ADSmartPickup/modDesc.xml @@ -1,12 +1,12 @@ masterdraco - 1.5.0.0 + 1.6.0.0 <en>AD Smart Pickup</en> - + icon.dds diff --git a/README.md b/README.md index bf0f833..b21a6ed 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,20 @@ Slå fra ved at vælge en anden enkelt vare. "Mest brug for" defineres i `ADSupplyPlanner.scoreNeed` (`adSupplyPlanner.lua`). +### Fabriksstyret (v1.6): målet er et produktionspunkt + +Er aflæsningsmålet en fabrik (produktionspunkt), **erstattes** ingredienslisten af inputs fra +fabrikkens ønskede produktioner — slår du fx TMR til i en AIO-fabrik, begynder traktoren at hente +ensilage/hø/halm/roer/mineralfoder af sig selv; slår du den fra, holder den op. Dit multi-valg er kun +tændknappen (og det gendannes ved stop). Listen genudledes hver tur, begrænset til det vognen kan bære. + +- **Ønsket produktion** = tændt, ELLER slukket af kortet med status "mangler input" (Dakotas + `ExtendedProductionPoint` slår produktioner fra automatisk, når de løber tør — dem skal vi netop + fodre). Manuelt slukket = ikke ønsket. +- **Prioritet = tid til tom:** `lager / (Σ amount × cyclesPerMinute)` over ønskede produktioner — + den vare der løber tør først hentes først, uanset relativ fyldning. Loggen viser forbrug og minutter. +- Fabrik uden ønskede produktioner, eller inputs vognen ikke kan bære → dit multi-valg som hidtil. + ## Fælles - Wrapper `ADMultipleTargetsManager.getNextPickup` (via den globale `FS25_AutoDrive`-tabel); rører ikke AutoDrive-zip'en. diff --git a/tests/test_adSmartPickup.lua b/tests/test_adSmartPickup.lua index b6d2250..b66b99b 100644 --- a/tests/test_adSmartPickup.lua +++ b/tests/test_adSmartPickup.lua @@ -290,6 +290,71 @@ bareStation.owningPlaceable = {spec_productionPoint = {productionPoint = {storag feeder = makeSupplyDriver(markerByIndex(1), markerByIndex(10), {HAY, GRASS, SILAGE}) check("S10 lager læses fra produktionspunktet", manager:getNextPickup(feeder, false), 3) +-- ==== Fabriksstyret forsyning (aflæsningsmålet er et produktionspunkt) ==== +ProductionPoint = {PROD_STATUS = {INACTIVE = 0, RUNNING = 1, MISSING_INPUTS = 2, NO_OUTPUT_SPACE = 3}} +local WATER = 40 +FILL_TYPE_NAMES[WATER] = "WATER" +FS25_AutoDrive.AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically = function(vehicle) return vehicle.carriable end + +-- Fabrik med TMR-opskrift: sluger ensilage, nipper til hø. Vand kan vognen ikke bære. +local function buildFactoryWorld(tmrEnabled, tmrStatus) + buildFeedWorld() + local pp = { + enabled = {tmr = tmrEnabled}, + productions = { + {id = "tmr", cyclesPerMinute = 1, status = tmrStatus, inputs = {{type = SILAGE, amount = 250}, {type = HAY, amount = 10}, {type = WATER, amount = 1000}}}, + {id = "off", cyclesPerMinute = 1, status = ProductionPoint.PROD_STATUS.INACTIVE, inputs = {{type = GRASS, amount = 500}}}, + }, + getIsProductionEnabled = function(self, id) return self.enabled[id] == true end, + } + local factory = {spec_productionPoint = {productionPoint = pp}, getName = function() return "AIO" end} + unloadStations = {makeUnloadStation(110, 5000, 10, blanderLevels, {[HAY] = 100000, [GRASS] = 100000, [SILAGE] = 100000, [WATER] = 100000}, factory)} + local driver = makeSupplyDriver(markerByIndex(1), markerByIndex(10), {HAY, GRASS}) + driver.carriable = {HAY, GRASS, SILAGE} + return driver, pp +end + +-- F1: tændt produktion styrer listen: ensilage (200 min til tom) vinder over hø (8000 min) +-- og over græs, som er relativt tommest men ikke et input i fabrikken +local factoryDriver = buildFactoryWorld(true, ProductionPoint.PROD_STATUS.RUNNING) +check("F1 fabrikkens input med kortest tid til tom", manager:getNextPickup(factoryDriver, false), 4) +check("F1 AD's vare skiftet til ensilage (ikke i brugerens valg)", factoryDriver.ad.stateModule.fillType, SILAGE) +check("F1 log nævner minutter til tom", logLines[#logLines]:find("min til tom", 1, true) ~= nil, true) + +-- F2: næste tur — listen genudledes fra fabrikken, ikke fra brugerens valg +blanderLevels[SILAGE] = 99500 +check("F2 fuld ensilage -> hø (næste fabriks-input)", manager:getNextPickup(factoryDriver, false), 1) + +-- F3: AD stopper -> brugerens OPRINDELIGE valg gendannes, ikke fabrikkens liste +factoryDriver.ad.stateModule.active = false +listener:update(16) +check("F3 brugerens valg gendannet ved stop", #factoryDriver.ad.stateModule.selectedFillTypes, 2) + +-- F4: manuelt slukket produktion -> fallback til brugerens valg (græs er tommest) +factoryDriver = buildFactoryWorld(false, ProductionPoint.PROD_STATUS.INACTIVE) +local function countLogsSince(startIndex, needle) + local count = 0 + for i = startIndex + 1, #logLines do + if logLines[i]:find(needle, 1, true) then count = count + 1 end + end + return count +end +local logMark = #logLines +check("F4 slukket fabrik -> brugerens valg", manager:getNextPickup(factoryDriver, false), 3) +check("F4 loggen forklarer fallback", countLogsSince(logMark, "ingen ønskede produktioner"), 1) +manager:getNextPickup(factoryDriver, false) +check("F4 forklaringen logges kun én gang", countLogsSince(logMark, "ingen ønskede produktioner"), 1) + +-- F5: auto-slukket af kortet pga. manglende input -> stadig fabrikkens liste +factoryDriver = buildFactoryWorld(false, ProductionPoint.PROD_STATUS.MISSING_INPUTS) +check("F5 auto-slukket fabrik fodres stadig", manager:getNextPickup(factoryDriver, false), 4) + +-- F6: produktionspunkt uden getIsProductionEnabled -> activeProductions-listen bruges +local fallbackDriver, fallbackPp = buildFactoryWorld(false, ProductionPoint.PROD_STATUS.RUNNING) +fallbackPp.getIsProductionEnabled = nil +fallbackPp.activeProductions = {fallbackPp.productions[1]} +check("F6 activeProductions som fallback", manager:getNextPickup(fallbackDriver, false), 4) + -- L1: moden valgte siloen -> AutoDrive skal stå og læsse (rotation skjules under opgaven) resetWorld() local loadLevels = {[SUGARBEET] = 5000} diff --git a/tests/test_adSupplyPlanner.lua b/tests/test_adSupplyPlanner.lua index b4dd5ed..123d91f 100644 --- a/tests/test_adSupplyPlanner.lua +++ b/tests/test_adSupplyPlanner.lua @@ -62,5 +62,51 @@ check("mindst-først: højere niveau er ikke bedre", ADSupplyPlanner.isBetterSou check("fyldest-først: højere niveau er bedre", ADSupplyPlanner.isBetterSource(900, 500, true), true) check("første kandidat er altid bedre end ingen", ADSupplyPlanner.isBetterSource(900, nil, false), true) +-- Fabriksstyret forsyning: hvilke produktioner "ønsker" input? +local MISSING_INPUTS, INACTIVE = 2, 0 +check("tændt produktion ønskes", ADSupplyPlanner.isProductionWanted(true, INACTIVE, MISSING_INPUTS), true) +check("auto-slukket pga. mangel ønskes stadig", ADSupplyPlanner.isProductionWanted(false, MISSING_INPUTS, MISSING_INPUTS), true) +check("manuelt slukket ønskes ikke", ADSupplyPlanner.isProductionWanted(false, INACTIVE, MISSING_INPUTS), false) +check("ukendt status uden tænd -> ikke ønsket", ADSupplyPlanner.isProductionWanted(false, nil, MISSING_INPUTS), false) + +-- collectProductionDemand: liter/min pr. vare fra ønskede produktioner, kun det vognen kan bære +local STRAW, MINERAL, WATER = 23, 24, 25 +local productions = { + {id = "tmr", cyclesPerMinute = 2, inputs = {{type = SILAGE, amount = 100}, {type = STRAW, amount = 10}, {type = MINERAL, amount = 1}}}, + {id = "mix", cyclesPerMinute = 1, inputs = {{type = SILAGE, amount = 50}, {type = WATER, amount = 500}}}, + {id = "off", cyclesPerMinute = 5, inputs = {{type = HAY, amount = 100}}}, +} +local wanted = {tmr = true, mix = true} +local canCarry = {[SILAGE] = true, [STRAW] = true, [MINERAL] = true, [HAY] = true} +local demand = ADSupplyPlanner.collectProductionDemand(productions, function(p) return wanted[p.id] == true end, canCarry) +check("forbrug summeres på tværs af produktioner", demand[SILAGE], 250) +check("forbrug ganges med cyklusser/min", demand[STRAW], 20) +check("uønsket produktion tæller ikke", demand[HAY], nil) +check("vare vognen ikke kan bære udelades", demand[WATER], nil) +check("tom produktionsliste -> tomt behov", next(ADSupplyPlanner.collectProductionDemand({}, function() return true end, canCarry)), nil) +check("produktion uden inputs tåles", next(ADSupplyPlanner.collectProductionDemand({{id = "x", cyclesPerMinute = 1}}, function() return true end, canCarry)), nil) + +-- minutesToEmpty +check("minutter til tom", ADSupplyPlanner.minutesToEmpty(1000, 250), 4) +check("intet forbrug -> nil", ADSupplyPlanner.minutesToEmpty(1000, 0), nil) +check("ukendt forbrug -> nil", ADSupplyPlanner.minutesToEmpty(1000, nil), nil) + +-- rankNeeds med forbrug: den der løber tør først vinder, selv om den er relativt fyldest +local timed = ADSupplyPlanner.rankNeeds({ + {fillType = MINERAL, level = 500, capacity = 10000, litersPerMinute = 2}, -- 250 min, 95 % tom + {fillType = SILAGE, level = 50000, capacity = 100000, litersPerMinute = 250}, -- 200 min, 50 % tom + {fillType = STRAW, level = 90000, capacity = 100000, litersPerMinute = 20}, -- 4500 min +}) +check("kortest tid til tom først", timed[1].fillType, SILAGE) +check("dernæst næstkortest", timed[2].fillType, MINERAL) +check("minutesToEmpty følger med", timed[1].minutesToEmpty, 200) +check("vare med forbrug slår vare uden", ADSupplyPlanner.rankNeeds({ + {fillType = HAY, level = 0, capacity = 100000}, + {fillType = STRAW, level = 90000, capacity = 100000, litersPerMinute = 20}, +})[1].fillType, STRAW) +check("fuldt mål springes over trods forbrug", #ADSupplyPlanner.rankNeeds({ + {fillType = STRAW, level = 99500, capacity = 100000, litersPerMinute = 20}, +}), 0) + print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) os.exit(failures == 0 and 0 or 1)