348 lines
15 KiB
Lua
348 lines
15 KiB
Lua
-- 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
|