-- 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