From d31fbbec37af0f0b2392843ddcf0944bd473cac5 Mon Sep 17 00:00:00 2001 From: masterdraco Date: Wed, 23 Sep 2026 09:51:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20adSources=20=E2=80=94=20fabrik,=20silo?= =?UTF-8?q?=20og=20objektlager=20som=20kilder;=20paller=20ved=20spawner;?= =?UTF-8?q?=20salgssteder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FS25_ADSmartPickup/adSources.lua | 270 +++++++++++++++++++++++++++++++ tests/test_adSources.lua | 184 +++++++++++++++++++++ 2 files changed, 454 insertions(+) create mode 100644 FS25_ADSmartPickup/adSources.lua create mode 100644 tests/test_adSources.lua diff --git a/FS25_ADSmartPickup/adSources.lua b/FS25_ADSmartPickup/adSources.lua new file mode 100644 index 0000000..a9a02c8 --- /dev/null +++ b/FS25_ADSmartPickup/adSources.lua @@ -0,0 +1,270 @@ +-- AD Sources +-- Kilder til udkørsel: hvad står der ved en AD-markør (fabrik, silo eller objektlager), +-- hvilke varer rummer/producerer den, hvor meget, og hvordan tages paller ud af et +-- objektlager. Spil-adapter — de rene beslutninger ligger i adOutboundPlanner. + +ADSources = {} + +ADSources.LOG_PREFIX = "ADSmartPickup: " +-- Objektlager tættere end dette på markøren regnes som markørens (samme som triggere). +ADSources.MAX_DISTANCE = 40 +-- Paller der står inden for denne afstand af fabrikkens pallespawner tæller som fabrikkens output. +ADSources.PALLET_RADIUS = 25 + +local function distance2(ax, az, bx, bz) + local dx, dz = ax - bx, az - bz + return dx * dx + dz * dz +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 getPlaceableName(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 + +-- Pallevarer har en pallefil i spillets fillType-definition (halmkurve, mel, brød ...). +function ADSources.isPalletFillType(fillType) + local desc = g_fillTypeManager ~= nil and g_fillTypeManager.getFillTypeByIndex ~= nil and g_fillTypeManager:getFillTypeByIndex(fillType) or nil + return desc ~= nil and desc.palletFilename ~= nil +end + +local function getProductionPoint(placeable) + return placeable ~= nil and placeable.spec_productionPoint ~= nil and placeable.spec_productionPoint.productionPoint or nil +end + +-- Nærmeste objektlager inden for MAX_DISTANCE af waypointet, som farmen må bruge. +local function findObjectStorageNear(wayPoint, farmId) + local placeables = g_currentMission ~= nil and g_currentMission.placeableSystem ~= nil and g_currentMission.placeableSystem.placeables or {} + local access = g_currentMission ~= nil and g_currentMission.accessHandler or nil + local best, bestDistance = nil, ADSources.MAX_DISTANCE * ADSources.MAX_DISTANCE + for _, placeable in pairs(placeables) do + if placeable.spec_objectStorage ~= nil and (access == nil or access:canFarmAccess(farmId, placeable)) then + local x, z = getNodePosition(placeable.rootNode) + if x ~= nil then + local d = distance2(wayPoint.x, wayPoint.z, x, z) + if d < bestDistance then + best, bestDistance = placeable, d + end + end + end + end + return best +end + +-- {kind = "factory"|"silo"|"objectStorage", placeable, station|nil, wayPointId, name} eller nil. +function ADSources.identify(adEnv, wayPointId, farmId) + local wayPoint = adEnv.ADGraphManager:getWayPointById(wayPointId) + if wayPoint == nil then + return nil + end + local marker = adEnv.ADGraphManager.getMapMarkerByWayPointId ~= nil and adEnv.ADGraphManager:getMapMarkerByWayPointId(wayPointId) or nil + local markerName = marker ~= nil and tostring(marker.name) or tostring(wayPointId) + local station = ADSmartPickup.getLoadStationAtWayPointId(adEnv, wayPointId, farmId) + local placeable = station ~= nil and station.owningPlaceable or nil + if placeable ~= nil and getProductionPoint(placeable) ~= nil then + return {kind = "factory", placeable = placeable, station = station, wayPointId = wayPointId, name = getPlaceableName(placeable, markerName)} + end + if placeable ~= nil and placeable.spec_silo ~= nil then + return {kind = "silo", placeable = placeable, station = station, wayPointId = wayPointId, name = getPlaceableName(placeable, markerName)} + end + local storage = findObjectStorageNear(wayPoint, farmId) + if storage ~= nil then + return {kind = "objectStorage", placeable = storage, station = nil, wayPointId = wayPointId, name = getPlaceableName(storage, markerName)} + end + return nil +end + +local function isPalletVehicle(vehicle) + return vehicle ~= nil and (vehicle.spec_pallet ~= nil or vehicle.isPallet == true) +end + +local function getPalletContent(vehicle) + if vehicle.getFillUnitFillType ~= nil and vehicle.getFillUnitFillLevel ~= nil then + return vehicle:getFillUnitFillType(1), vehicle:getFillUnitFillLevel(1) or 0 + end + local units = vehicle.spec_fillUnit ~= nil and vehicle.spec_fillUnit.fillUnits or nil + if units ~= nil and units[1] ~= nil then + return units[1].fillType, units[1].fillLevel or 0 + end + return nil, 0 +end + +-- Liter af varen på paller der står ved fabrikkens spawner (pallespawneren tømmer lageret +-- ud på jorden løbende, så det er dér varen fysisk er). +local function getPalletsNearSpawner(placeable, fillType, farmId) + local productionPoint = getProductionPoint(placeable) + local spawner = productionPoint ~= nil and productionPoint.palletSpawner or nil + local node = spawner ~= nil and spawner.spawnPlaces ~= nil and spawner.spawnPlaces[1] ~= nil and spawner.spawnPlaces[1].startNode or placeable.rootNode + local sx, sz = getNodePosition(node) + if sx == nil then + return 0 + end + local vehicles = g_currentMission ~= nil and g_currentMission.vehicleSystem ~= nil and g_currentMission.vehicleSystem.vehicles or {} + local total = 0 + local radius2 = ADSources.PALLET_RADIUS * ADSources.PALLET_RADIUS + for _, vehicle in pairs(vehicles) do + if isPalletVehicle(vehicle) and (vehicle.getOwnerFarmId == nil or vehicle:getOwnerFarmId() == farmId) then + local palletFillType, level = getPalletContent(vehicle) + local x, z = getNodePosition(vehicle.rootNode) + if palletFillType == fillType and x ~= nil and distance2(sx, sz, x, z) <= radius2 then + total = total + level + end + end + end + return total +end + +local function newProduct(fillType, available, level, capacity, isPallet) + local name = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(fillType) or tostring(fillType) + local title = g_fillTypeManager ~= nil and g_fillTypeManager.getFillTypeTitleByIndex ~= nil and g_fillTypeManager:getFillTypeTitleByIndex(fillType) or nil + return {fillType = fillType, name = name, title = title or name, available = available, level = level, capacity = capacity, isPallet = isPallet} +end + +local function getFactoryProducts(source, farmId) + local productionPoint = getProductionPoint(source.placeable) + local storage = productionPoint ~= nil and productionPoint.storage or nil + local seen, products = {}, {} + for _, production in ipairs(productionPoint ~= nil and productionPoint.productions or {}) do + for _, output in ipairs(production.outputs or {}) do + local fillType = output.type + if fillType ~= nil and not seen[fillType] then + seen[fillType] = true + local level = storage ~= nil and storage.getFillLevel ~= nil and (storage:getFillLevel(fillType) or 0) or 0 + local capacity = storage ~= nil and storage.getCapacity ~= nil and (storage:getCapacity(fillType) or 0) or 0 + local isPallet = ADSources.isPalletFillType(fillType) + local available = level + (isPallet and getPalletsNearSpawner(source.placeable, fillType, farmId) or 0) + table.insert(products, newProduct(fillType, available, level, capacity, isPallet)) + end + end + end + return products +end + +local function getSiloProducts(source) + local storages = source.placeable.spec_silo ~= nil and source.placeable.spec_silo.storages or {} + local levels, capacities = {}, {} + for _, storage in pairs(storages) do + for fillType, level in pairs(storage.fillLevels or {}) do + if (level or 0) > 0 then + levels[fillType] = (levels[fillType] or 0) + level + capacities[fillType] = (capacities[fillType] or 0) + (storage.getCapacity ~= nil and (storage:getCapacity(fillType) or 0) or 0) + end + end + end + local products = {} + for fillType, level in pairs(levels) do + table.insert(products, newProduct(fillType, level, level, capacities[fillType] or 0, ADSources.isPalletFillType(fillType))) + end + table.sort(products, function(a, b) return a.fillType < b.fillType end) + return products +end + +-- Et objektlagers post: {fillType, fillLevel pr. objekt} for paller og baller. +local function getObjectInfoContent(objectInfo) + local object = objectInfo.objects ~= nil and objectInfo.objects[1] or nil + if object == nil then + return nil, 0 + end + local attributes = object.palletAttributes or object.baleAttributes + if attributes ~= nil then + return attributes.fillType, attributes.fillLevel or 0 + end + return object.fillType, object.fillLevel or 0 +end + +local function getObjectStorageProducts(source) + local spec = source.placeable.spec_objectStorage + local products = {} + for _, objectInfo in ipairs(spec ~= nil and spec.objectInfos or {}) do + local fillType, perObject = getObjectInfoContent(objectInfo) + local count = objectInfo.numObjects or 0 + if fillType ~= nil and count > 0 then + table.insert(products, newProduct(fillType, count * perObject, spec.numStoredObjects or count, spec.capacity or 0, true)) + end + end + return products +end + +-- Varer ved kilden med tilgængelig mængde: { {fillType, name, title, available, level, capacity, isPallet} }. +function ADSources.getProducts(adEnv, source, farmId) + if source.kind == "factory" then + return getFactoryProducts(source, farmId) + elseif source.kind == "silo" then + return getSiloProducts(source) + elseif source.kind == "objectStorage" then + return getObjectStorageProducts(source) + end + return {} +end + +-- Objektlager: objectInfo-indeks + antal for varen; nil når lageret ikke har den. +function ADSources.getObjectInfoIndex(source, fillType) + local spec = source.placeable ~= nil and source.placeable.spec_objectStorage or nil + for index, objectInfo in ipairs(spec ~= nil and spec.objectInfos or {}) do + local infoFillType = getObjectInfoContent(objectInfo) + if infoFillType == fillType and (objectInfo.numObjects or 0) > 0 then + return index, objectInfo.numObjects + end + end + return nil +end + +function ADSources.countStored(source, fillType) + local _, count = ADSources.getObjectInfoIndex(source, fillType) + return count or 0 +end + +-- Bed lageret spawne ÉT objekt (det knappen i spillet gør). connection = nil, så +-- AutomaticBaleStorage-moddens kø (der kun fanger klient-kald) ikke blander sig. +function ADSources.spawnOne(source, objectInfoIndex) + local placeable = source.placeable + if placeable == nil or placeable.removeAbstractObjectsFromStorage == nil then + return false + end + local ok = pcall(placeable.removeAbstractObjectsFromStorage, placeable, objectInfoIndex, 1, nil) + return ok == true +end + +function ADSources.stationAccepts(station, fillType) + if station.acceptedFillTypes ~= nil then + return station.acceptedFillTypes[fillType] == true + end + if station.getIsFillTypeSupported ~= nil then + return station:getIsFillTypeSupported(fillType) == true + end + return station.supportedFillTypes ~= nil and station.supportedFillTypes[fillType] == true +end + +-- Markører hvis aflæsningsstation er et rigtigt salgssted: { {marker, station} }. +function ADSources.getSellMarkers(adEnv, farmId) + local result = {} + for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do + local station = ADSmartPickup.getUnloadStationAtWayPointId(adEnv, marker.id, farmId) + if station ~= nil and ADPrices.isSellingStation(station) then + table.insert(result, {marker = marker, station = station}) + end + end + return result +end + +function ADSources.getSourcePlaceables(sources) + local set = {} + for _, source in ipairs(sources or {}) do + if source.placeable ~= nil then + set[source.placeable] = true + end + end + return set +end diff --git a/tests/test_adSources.lua b/tests/test_adSources.lua new file mode 100644 index 0000000..26450d0 --- /dev/null +++ b/tests/test_adSources.lua @@ -0,0 +1,184 @@ +-- Kør: luajit tests/test_adSources.lua (fra repo-roden) +-- Kilde-genkendelse ved en AD-markør (fabrik / silo / objektlager), lagerstatus pr. vare, +-- paller ved fabrikkens spawner, salgssteder, objektlager-udtag. + +local SUGAR, FORAGE, BASKET, CRATE = 5, 6, 7, 8 +local DESC = { + [SUGAR] = {index = SUGAR, name = "SUGAR", title = "Sukker"}, + [FORAGE] = {index = FORAGE, name = "FORAGE", title = "TMR"}, + [BASKET] = {index = BASKET, name = "STRAW_BASKET", title = "Halmkurve", palletFilename = "basketPallet.xml"}, + [CRATE] = {index = CRATE, name = "STRAW_CRATE", title = "Halmkasser", palletFilename = "cratePallet.xml"}, +} +g_fillTypeManager = { + getFillTypeByIndex = function(_, index) return DESC[index] end, + getFillTypeNameByIndex = function(_, index) return DESC[index] and DESC[index].name end, + getFillTypeTitleByIndex = function(_, index) return DESC[index] and DESC[index].title end, +} +Logging = {info = function() end, warning = function() end} +getWorldTranslation = function(node) return node.x, 0, node.z end +SellingStation = {} + +dofile("FS25_ADSmartPickup/adPrices.lua") +dofile("FS25_ADSmartPickup/adSources.lua") + +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 + +-- ---- fakes ---- +local wayPoints, markers = {}, {} +local function addMarker(id, name, x, z) + wayPoints[id] = {id = id, x = x, z = z} + table.insert(markers, {id = id, markerIndex = #markers + 1, name = name, group = "g"}) +end +local adEnv = {ADGraphManager = { + getWayPointById = function(_, id) return wayPoints[id] end, + getMapMarkers = function() return markers end, + getMapMarkerByWayPointId = function(_, id) for _, m in ipairs(markers) do if m.id == id then return m end end end, +}} + +local function storageOf(levels, capacities) + return { + fillLevels = levels, + getFillLevel = function(_, ft) return levels[ft] or 0 end, + getCapacity = function(_, ft) return capacities[ft] or 0 end, + } +end + +local sugarStorage = storageOf({[SUGAR] = 150000, [BASKET] = 0}, {[SUGAR] = 180000, [BASKET] = 8000}) +local factory = { + rootNode = {x = 100, z = 100}, getName = function() return "Sugar mill" end, + spec_productionPoint = {productionPoint = { + productions = {{outputs = {{type = SUGAR, amount = 1}}}, {outputs = {{type = BASKET, amount = 1}}}}, + storage = sugarStorage, + palletSpawner = {spawnPlaces = {{startNode = {x = 110, z = 100}}}}, + }}, +} +local factoryStation = {owningPlaceable = factory} +local silo = {rootNode = {x = 200, z = 200}, spec_silo = {storages = {storageOf({[FORAGE] = 74500}, {[FORAGE] = 12000000})}}, getName = function() return "Farma" end} +local siloStation = {owningPlaceable = silo} +local spawnCalls = {} +local objectStorage = { + rootNode = {x = 300, z = 300}, ownerFarmId = 1, getName = function() return "Objektlager" end, + spec_objectStorage = { + objectInfos = { + {objects = {{palletAttributes = {fillType = BASKET, fillLevel = 1000}}}, numObjects = 8}, + {objects = {{baleAttributes = {fillType = FORAGE, fillLevel = 4000}}}, numObjects = 2}, + }, + numStoredObjects = 10, capacity = 4000, + }, + removeAbstractObjectsFromStorage = function(self, index, amount, connection) table.insert(spawnCalls, {index, amount, connection}) end, +} +local sellStation = {owningPlaceable = {rootNode = {x = 500, z = 500}}, isa = function(_, c) return c == SellingStation end, isSellingPoint = true, acceptedFillTypes = {[SUGAR] = true}, getName = function() return "Grain Elevator" end} +local factoryUnload = {owningPlaceable = factory, isa = function() return true end, isSellingPoint = false} + +addMarker(1, "Suger Load", 100, 90) +addMarker(2, "silo 2", 200, 190) +addMarker(3, "Objektlager", 300, 280) -- 20 m fra lageret +addMarker(4, "Langt væk", 300, 240) -- 60 m fra lageret +addMarker(5, "selling station", 500, 490) +addMarker(6, "sugar offload", 100, 80) + +local loadAt = {[1] = factoryStation, [2] = siloStation} +local unloadAt = {[5] = sellStation, [6] = factoryUnload} +ADSmartPickup = { + getLoadStationAtWayPointId = function(_, id) return loadAt[id] end, + getUnloadStationAtWayPointId = function(_, id) return unloadAt[id] end, + getOwnStorageLevel = function(station, fillTypes) + local total = 0 + for _, storage in pairs(station.owningPlaceable.spec_silo.storages) do + for _, ft in ipairs(fillTypes) do total = total + storage:getFillLevel(ft) end + end + return total + end, +} +local pallets = {} +local function addPallet(fillType, level, x, z, farmId) + table.insert(pallets, {spec_pallet = {}, rootNode = {x = x, z = z}, getOwnerFarmId = function() return farmId or 1 end, + getFillUnitFillType = function() return fillType end, getFillUnitFillLevel = function() return level end}) +end +g_currentMission = { + placeableSystem = {placeables = {factory, silo, objectStorage}}, + accessHandler = {canFarmAccess = function(_, farmId, placeable) return true end}, + vehicleSystem = {vehicles = pallets}, +} + +-- S1: fabrik +local source1 = ADSources.identify(adEnv, 1, 1) +check("S1 fabrik", source1.kind, "factory") +check("S1 navn", source1.name, "Sugar mill") +local products1 = ADSources.getProducts(adEnv, source1, 1) +check("S1 to produkter", #products1, 2) +check("S1 SUGAR available", products1[1].available, 150000) +check("S1 SUGAR level", products1[1].level, 150000) +check("S1 SUGAR capacity", products1[1].capacity, 180000) +check("S1 SUGAR bulk", products1[1].isPallet, false) +check("S1 BASKET er palle", products1[2].isPallet, true) +check("S1 BASKET tomt", products1[2].available, 0) + +-- S2: silo +local source2 = ADSources.identify(adEnv, 2, 1) +check("S2 silo", source2.kind, "silo") +local products2 = ADSources.getProducts(adEnv, source2, 1) +check("S2 én vare", #products2, 1) +check("S2 FORAGE", products2[1].fillType, FORAGE) +check("S2 level", products2[1].level, 74500) +check("S2 capacity", products2[1].capacity, 12000000) + +-- S3: objektlager 20 m fra markøren +local source3 = ADSources.identify(adEnv, 3, 1) +check("S3 objektlager", source3.kind, "objectStorage") +local products3 = ADSources.getProducts(adEnv, source3, 1) +check("S3 to varer", #products3, 2) +check("S3 kurve available", products3[1].available, 8000) +check("S3 kurve er palle", products3[1].isPallet, true) +check("S3 level = antal objekter", products3[1].level, 10) +check("S3 capacity", products3[1].capacity, 4000) +check("S3 baller tæller som palle", products3[2].isPallet, true) +local index, count = ADSources.getObjectInfoIndex(source3, BASKET) +check("S3 objectInfo-indeks", index, 1) +check("S3 antal", count, 8) +check("S3 countStored", ADSources.countStored(source3, FORAGE), 2) +check("S3 ukendt vare", ADSources.getObjectInfoIndex(source3, SUGAR), nil) + +-- S4: paller ved fabrikkens spawner tæller med (inden for 25 m af spawn-noden) +addPallet(BASKET, 1000, 112, 100) +addPallet(BASKET, 1000, 115, 105) +addPallet(BASKET, 1000, 160, 100) -- 50 m væk: tæller ikke +addPallet(BASKET, 1000, 112, 102, 2) -- anden farm: tæller ikke +local products4 = ADSources.getProducts(adEnv, source1, 1) +check("S4 paller ved spawner", products4[2].available, 2000) +check("S4 bulk uændret", products4[1].available, 150000) + +-- S5: salgssteder = kun markører hvis aflæsningsstation er et rigtigt salgssted +local sells = ADSources.getSellMarkers(adEnv, 1) +check("S5 ét salgssted", #sells, 1) +check("S5 markør", sells[1].marker.id, 5) +check("S5 tager SUGAR", ADSources.stationAccepts(sells[1].station, SUGAR), true) +check("S5 tager ikke FORAGE", ADSources.stationAccepts(sells[1].station, FORAGE), false) + +-- S6: spawnOne kalder spillets udtag med 1 objekt og uden klientforbindelse +check("S6 spawn ok", ADSources.spawnOne(source3, 1), true) +check("S6 indeks", spawnCalls[1][1], 1) +check("S6 antal", spawnCalls[1][2], 1) +check("S6 connection nil", spawnCalls[1][3], nil) +check("S6 fejl -> false", ADSources.spawnOne({kind = "objectStorage", placeable = {removeAbstractObjectsFromStorage = function() error("x") end}}, 1), false) + +-- S7: markør uden station og >40 m fra objektlager -> nil +check("S7 ingen kilde", ADSources.identify(adEnv, 4, 1), nil) +check("S7 ukendt waypoint", ADSources.identify(adEnv, 99, 1), nil) + +-- S8: hjælpere +check("S8 isPalletFillType", ADSources.isPalletFillType(BASKET), true) +check("S8 bulk", ADSources.isPalletFillType(SUGAR), false) +local excluded = ADSources.getSourcePlaceables({source1, source2}) +check("S8 udelukkede placeables", excluded[factory] and excluded[silo], true) + +print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL")) +os.exit(failures == 0 and 0 or 1)