Files
fs25-adsmartpickup/FS25_ADSmartPickup/adSupplyPlanner.lua
T

348 lines
15 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- AD Supply Planner
-- Rene funktioner (ingen spil-API) til behovsstyret forsyning: hvilken ingrediens
-- har aflæsningsmålet mest brug for, og hvilken ingrediensliste gælder for køretøjet.
ADSupplyPlanner = {}
-- Mindre fri plads end dette i målet regnes som "fuldt" (undgår ture med læs der ikke kan komme af).
ADSupplyPlanner.MIN_FREE_LITERS = 1000
-- Varer hvis kilder LØBER FULDE (dyr/produktion fylder tanken løbende og går i stå
-- når den er fuld): her hentes den fyldeste kilde først. Alt andet er lagervarer,
-- hvor den mindst fyldte silo tømmes først så rester ryddes og siloer frigives.
ADSupplyPlanner.FULLEST_FIRST_FILL_TYPES = {
MILK = true, GOATMILK = true, BUFFALOMILK = true, SHEEPMILK = true,
LIQUIDMANURE = true, DIGESTATE = true,
}
-- fillTypeNames: navnene på de varer der hentes på turen. Kun når ALLE er
-- "løber fuld"-varer vendes strategien; blandet eller ukendt valg = mindst først.
function ADSupplyPlanner.prefersFullestSource(fillTypeNames)
if #fillTypeNames == 0 then
return false
end
for _, name in ipairs(fillTypeNames) do
if not ADSupplyPlanner.FULLEST_FIRST_FILL_TYPES[name] then
return false
end
end
return true
end
-- Er en kilde med dette niveau bedre end den hidtil bedste (nil = ingen endnu)?
function ADSupplyPlanner.isBetterSource(level, bestLevel, prefersFullest)
if bestLevel == nil then
return true
end
if prefersFullest then
return level > bestLevel
end
return level < bestLevel
end
function ADSupplyPlanner.copyList(list)
local copy = {}
for index, value in ipairs(list) do
copy[index] = value
end
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
-- Er varen et INPUT til fabrikken (og ikke også et output)? Så må den aldrig
-- hentes derfra — det ville tømme fabrikken for det, den skal bruge.
function ADSupplyPlanner.isProductionInput(productions, fillType)
local isInput, isOutput = false, false
for _, production in ipairs(productions) do
for _, input in ipairs(production.inputs or {}) do
if input.type == fillType then isInput = true end
end
for _, output in ipairs(production.outputs or {}) do
if output.type == fillType then isOutput = true end
end
end
return isInput and not isOutput
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. 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
-- nil når læs-størrelsen er ukendt, ellers om et fuldt læs kan komme af i målet.
function ADSupplyPlanner.fullLoadFits(freeSpace, loadCapacity)
if loadCapacity == nil or loadCapacity <= 0 then
return nil
end
return freeSpace >= loadCapacity
end
-- Wait-punkter: alle markører i en MAPPE med ordet "Wait" i navnet (uanset store/små),
-- fx mappen "Cow Wait". Her venter en traktor med rest-læs, til der igen er plads i
-- målet. Markørens eget navn tæller ikke — spillerens mark-ventepladser ("F91 Wait")
-- ligger i mark-mapperne og hører ikke til puljen.
function ADSupplyPlanner.isWaitMarker(marker)
local group = string.lower(tostring(marker.group or ""))
return string.find(group, "%f[%a]wait%f[%A]") ~= nil
end
-- candidates: {{marker, x, z}}; claims: {[marker.id] = køretøj}. Nærmeste punkt der er
-- ledigt (eller allerede reserveret af dette køretøj) og som isReachable(candidate)
-- godkender (valgfri; AutoDrive slukker sig selv, hvis den sendes mod et punkt uden rute).
-- nil hvis intet punkt duer. Nåelighed tjekkes kun for de punkter der er i spil, nærmeste først.
-- Wait-punkter hvor et ANDET køretøj fysisk holder (positions = {{x, z}}): hvert køretøj optager
-- højst ét punkt — det nærmeste inden for radius — så et nabopunkt 6 m væk ikke blokeres ved en fejl.
function ADSupplyPlanner.markOccupiedWaitPoints(candidates, positions, radius)
local occupied = {}
local limit = (radius or 6) * (radius or 6)
for _, position in ipairs(positions or {}) do
local best, bestDistance = nil, limit
for _, candidate in ipairs(candidates or {}) do
local dx, dz = candidate.x - position.x, candidate.z - position.z
local distance = dx * dx + dz * dz
if distance <= bestDistance then best, bestDistance = candidate, distance end
end
if best ~= nil then occupied[best.marker.id] = true end
end
return occupied
end
-- occupied (valgfri) = {[markerId] = true} fysisk optaget af andre; springes over medmindre man selv har reservationen.
function ADSupplyPlanner.pickNearestFreeWaitPoint(candidates, claims, x, z, vehicle, isReachable, occupied)
local free = {}
for _, candidate in ipairs(candidates) do
local owner = claims[candidate.marker.id]
local isTaken = occupied ~= nil and occupied[candidate.marker.id] == true and owner ~= vehicle
if (owner == nil or owner == vehicle) and not isTaken then
local dx, dz = candidate.x - x, candidate.z - z
table.insert(free, {candidate = candidate, distance = dx * dx + dz * dz})
end
end
table.sort(free, function(a, b) return a.distance < b.distance end)
for _, entry in ipairs(free) do
if isReachable == nil or isReachable(entry.candidate) then
return entry.candidate
end
end
return nil
end
-- Så stor en andel af målets kapacitet skal være fri, før en rest der er større end
-- det, kan afleveres (en 700.000 l-vogn kan aldrig tømme HELE resten i en 300.000 l-stald).
ADSupplyPlanner.WAIT_RETURN_FREE_FRACTION = 0.5
-- Kan den ventende køre hjem? Ja når hele resten passer, eller når målet er mindst
-- freeFraction tomt (standard WAIT_RETURN_FREE_FRACTION; run'ets "kør hjem under X %" =
-- freeFraction 1 - X/100). capacity nil = kun hele resten tæller.
function ADSupplyPlanner.canUnloadRest(freeSpace, rest, capacity, freeFraction)
local needed = rest
if capacity ~= nil and capacity > 0 then
needed = math.min(rest, capacity * (freeFraction or ADSupplyPlanner.WAIT_RETURN_FREE_FRACTION))
end
return freeSpace >= needed
end
-- needs: {{fillType, level, capacity[, litersPerMinute][, scoreOverride][, priority]}, ...}.
-- Returnerer NY liste, mest trængende først; ingredienser uden plads i målet er sorteret fra.
-- loadCapacity (valgfri): vognens kapacitet — varer hvor et FULDT læs kan komme af
-- går forud (så vognen ikke ender med rest-læs); passer intet, gælder ren trang.
-- scoreOverride: varer der deler ét lager (staldens fodertrug) får trugets fælles score,
-- så truget konkurrerer som ÉN trang; priority (fx spillets fodervægt: TMR 1,0 > hø 0,8 >
-- græs 0,4) afgør så rækkefølgen inden for lageret.
function ADSupplyPlanner.rankNeeds(needs, loadCapacity)
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,
litersPerMinute = need.litersPerMinute,
minutesToEmpty = minutesToEmpty,
fits = ADSupplyPlanner.fullLoadFits(freeSpace, loadCapacity),
score = need.scoreOverride or ADSupplyPlanner.scoreNeed(need.level, need.capacity, freeSpace, minutesToEmpty),
priority = need.priority or 0,
trough = need.scoreOverride ~= nil,
})
end
end
table.sort(ranked, function(a, b)
if a.fits ~= b.fits then
return a.fits == true
end
if a.score ~= b.score then
return a.score > b.score
end
if a.priority ~= b.priority then
return a.priority > b.priority
end
return a.fillType < b.fillType
end)
return ranked
end
-- ---- læs på vej ----
-- pending = {[fillType] = liter} som ANDRE traktorer allerede er på vej med til samme mål.
-- Almindelige lagre: niveau + på vej (højst kapaciteten). Foder i et fælles trug (scoreOverride +
-- troughCapacity): varens niveau stiger, og trugets fri plads falder med ALT foder på vej.
-- Returnerer NY liste; input røres ikke.
function ADSupplyPlanner.applyPending(needs, pending)
local troughPending = 0
for _, need in ipairs(needs) do
if need.scoreOverride ~= nil and pending[need.fillType] ~= nil then
troughPending = troughPending + pending[need.fillType]
end
end
local result = {}
for index, need in ipairs(needs) do
local copy = {}
for key, value in pairs(need) do copy[key] = value end
local extra = pending[need.fillType] or 0
if need.scoreOverride ~= nil and troughPending > 0 then
local free = math.max(0, (need.capacity - need.level) - troughPending)
copy.level = need.level + extra
copy.capacity = copy.level + free
local troughCapacity = need.troughCapacity or need.capacity
copy.scoreOverride = troughCapacity > 0 and math.max(0, need.scoreOverride - troughPending / troughCapacity) or 0
elseif extra > 0 then
copy.level = math.min(need.capacity, need.level + extra)
end
result[index] = copy
end
return result
end
-- ---- bedste foder først ----
-- Foder i et stald-trug deler plads. Lavere foder (ensilage/hø, vægt 0,8) må kun hentes når der er
-- bedre foder (TMR, 1,0) på listen, hvis truget er nødlidende: mindst så stor en andel fri.
ADSupplyPlanner.EMERGENCY_TROUGH_FREE = 0.75
-- need.trough = true for foder i et fælles trug; need.score = trugets fri andel; need.priority = fodervægt.
function ADSupplyPlanner.bestFoodPriority(needs)
local best = nil
for _, need in ipairs(needs or {}) do
if need.trough and (best == nil or (need.priority or 0) > best) then best = need.priority or 0 end
end
return best
end
function ADSupplyPlanner.isFoodAllowed(need, bestPriority)
if not need.trough or bestPriority == nil or (need.priority or 0) >= bestPriority then
return true
end
return (need.score or 0) >= ADSupplyPlanner.EMERGENCY_TROUGH_FREE
end
-- state: nil eller {ingredients, narrowedTo} fra forrige tur. selected: AD's aktuelle varevalg.
-- >1 valgt = (ny) ingrediensliste. Præcis den vare vi selv indsnævrede til = gemt liste.
-- Alt andet = brugeren har valgt noget andet -> nil (behovsstyring fra).
function ADSupplyPlanner.resolveIngredients(state, selected)
if type(selected) ~= "table" then
return nil
end
if #selected > 1 then
return ADSupplyPlanner.copyList(selected)
end
if state ~= nil and #selected == 1 and selected[1] == state.narrowedTo then
return ADSupplyPlanner.copyList(state.ingredients)
end
return nil
end
-- Trigger-noder (exactFillRootNode/triggerNode) i indlejrede tabeller, fx en stalds foderdata hvor feltstien
-- varierer mellem FS-versioner og mods (hønsehuset har kun foder-triggeren, ikke i aflæsningsstationen).
-- Højst maxDepth niveauer (standard 3); løkker og placeable-referencer følges ikke.
local SKIP_KEYS = {owningPlaceable = true, placeable = true, owner = true, parent = true, vehicle = true, target = true, mission = true}
function ADSupplyPlanner.findTriggerNodes(root, maxDepth)
local nodes, seen = {}, {}
local function visit(value, depth)
if type(value) ~= "table" or seen[value] or depth > (maxDepth or 3) then
return
end
seen[value] = true
for _, field in ipairs({"exactFillRootNode", "triggerNode"}) do
local node = rawget(value, field)
if node ~= nil and type(node) ~= "boolean" then table.insert(nodes, node) end
end
for key, child in pairs(value) do
if type(child) == "table" and not SKIP_KEYS[key] then
visit(child, depth + 1)
end
end
end
visit(root, 0)
return nodes
end
-- Parallelt foder (animalFood consumptionType PARALLEL, fx høns): dyrene spiser fra alle grupper samtidig,
-- så hver gruppe skal have sin andel af truget efter eatWeight. Returnerer gruppens niveau og kapacitet
-- (trugets kapacitet × gruppens andel) for gruppen der indeholder fillType; nil hvis varen ikke er foder.
function ADSupplyPlanner.parallelFoodShare(groups, fillLevels, capacity, fillType)
local totalWeight = 0
for _, group in ipairs(groups or {}) do totalWeight = totalWeight + (tonumber(group.eatWeight) or 0) end
if totalWeight <= 0 then return nil end
for _, group in ipairs(groups or {}) do
local isMember = false
for _, member in pairs(group.fillTypes or {}) do
if member == fillType then isMember = true end
end
if isMember then
local level = 0
for _, member in pairs(group.fillTypes or {}) do level = level + ((fillLevels or {})[member] or 0) end
return level, (capacity or 0) * (tonumber(group.eatWeight) or 0) / totalWeight
end
end
return nil
end