Files
fs25-adsmartpickup/FS25_ADSmartPickup/adStorageStats.lua
T
masterdraco b33f2a3b01 fix(kæde): højst 3 beskeder og samlede poster i Kræver handling
notifyProblems sender højst 3 beskeder pr. tick og samler resten i én
("+N manglende led — se fanen Kæder"). Alle kædeproblemer står i én rød post
("Kæder: N manglende led", scoret under rød fuld; klik folder kæderne ud) og
slukkede nødvendige fabrikker i højst én post ("Slukket men nødvendig: X +k").
2026-09-30 15:27:08 +02:00

422 lines
19 KiB
Lua

-- AD Storage Stats
-- Rene beregninger (ingen spil-API) til fanen Lagre: pr. vare i en bygning fyldning, værdi nu og
-- ved årets toppris, gårdens samlede beholdning og hvor mange døgn den rækker ved gårdens nettoforbrug.
ADStorageStats = {}
-- item = {fillType, level, capacity}; farmTotal = liter af varen på hele gården;
-- rate = {consumption, production} liter pr. døgn (eller nil); quote = {price, top} pr. liter (eller nil).
function ADStorageStats.itemStats(item, farmTotal, rate, quote)
local level = item.level or 0
local capacity = item.capacity or 0
local consumption = rate ~= nil and rate.consumption or 0
local production = rate ~= nil and rate.production or 0
-- palleskure: fyldning i paller af pladser (kapaciteten er et antal pladser, ikke liter)
local percent = capacity > 0 and math.floor(100 * level / capacity + 0.5) or nil
if item.count ~= nil and (item.slots or 0) > 0 then
percent = math.floor(100 * item.count / item.slots + 0.5)
end
return {
fillType = item.fillType,
level = level,
capacity = capacity,
count = item.count,
slots = item.slots,
percent = percent,
valueNow = quote ~= nil and quote.price ~= nil and math.floor(level * quote.price + 0.5) or nil,
valueTop = quote ~= nil and quote.top ~= nil and math.floor(level * quote.top + 0.5) or nil,
farmTotal = farmTotal or level,
consumption = consumption,
production = production,
net = production - consumption,
-- døgn til tom ved gårdens NETTO-forbrug; produktion ≥ forbrug = beholdningen stiger
days = consumption > production and (farmTotal or level) / (consumption - production) or nil,
rising = consumption > 0 and production >= consumption,
}
end
function ADStorageStats.buildingTotals(stats)
local now, top = 0, 0
for _, stat in ipairs(stats) do
now = now + (stat.valueNow or 0)
top = top + (stat.valueTop or 0)
end
return {valueNow = now, valueTop = top}
end
-- inventory = { {entries = {{fillType, level}}} } -> {[fillType] = liter}
function ADStorageStats.farmTotals(inventory)
local totals = {}
for _, building in ipairs(inventory or {}) do
for _, entry in ipairs(building.entries or {}) do
totals[entry.fillType] = (totals[entry.fillType] or 0) + (entry.level or 0)
end
end
return totals
end
-- Et stald-trug æder `perDay` liter i alt; fordel på fodertyperne efter hvad truget indeholder.
-- Tomt trug: alt på foderet med højest vægt (det der hentes først).
function ADStorageStats.splitTroughConsumption(perDay, levels, weights)
local total = 0
for _, level in pairs(levels or {}) do total = total + (level or 0) end
local result = {}
if total > 0 then
for fillType, level in pairs(levels) do
if (level or 0) > 0 then result[fillType] = perDay * level / total end
end
return result
end
local best, bestWeight = nil, -1
for fillType, weight in pairs(weights or {}) do
if weight > bestWeight or (weight == bestWeight and fillType < best) then best, bestWeight = fillType, weight end
end
if best ~= nil then result[best] = perDay end
return result
end
-- Deler en liste i rækker á `size` celler (gitter i fanen Lagre). Tom liste giver én tom række,
-- så bygningen stadig får sin navnerække.
function ADStorageStats.chunk(list, size)
local rows, current = {}, {}
for _, value in ipairs(list or {}) do
table.insert(current, value)
if #current == size then
table.insert(rows, current)
current = {}
end
end
if #current > 0 or #rows == 0 then
table.insert(rows, current)
end
return rows
end
-- "-14.4k +3.6k": forbrug med minus, produktion med plus pr. døgn; intet = "-".
function ADStorageStats.rateText(consumption, production, format)
local parts = {}
if (consumption or 0) > 0 then table.insert(parts, "-" .. format(consumption)) end
if (production or 0) > 0 then table.insert(parts, "+" .. format(production)) end
return #parts > 0 and table.concat(parts, " ") or "-"
end
-- Bygninger af én type (fanerne Fabrikker/Siloer/Stalde/Palleskure); rækkefølgen bevares.
function ADStorageStats.filterKind(inventory, kind)
local result = {}
for _, entry in ipairs(inventory or {}) do
if entry.kind == kind then table.insert(result, entry) end
end
return result
end
-- ---- vareoversigt (v1.52): én række pr. vare, der folder sig ud til bygningerne ----
-- Tærskler for farverne: døgn til tom (gårdens netto), fyldning i % (produceret vare / stald-trug).
ADStorageStats.DAYS_RED, ADStorageStats.DAYS_YELLOW = 1, 3
ADStorageStats.FULL_RED, ADStorageStats.FULL_YELLOW = 95, 85
ADStorageStats.BARN_LOW_RED, ADStorageStats.BARN_LOW_YELLOW = 10, 25
-- "Sælg nu": prisen er mindst 90 % af årets top, og partiet er værd at køre med
ADStorageStats.SELL_RATIO, ADStorageStats.SELL_MIN_VALUE = 0.9, 10000
local function percentOf(level, capacity)
return (capacity or 0) > 0 and math.floor(100 * (level or 0) / capacity + 0.5) or nil
end
-- inventory (fanens bygninger) -> { {fillType, title, level, capacity, percent, count, isOutput,
-- buildings = {{name, placeable, level, capacity, percent, count, slots, isOutput}}} } — varer efter titel,
-- bygninger med mest først. count (paller) summeres kun når alle bygninger melder paller.
function ADStorageStats.groupByFillType(inventory, titleOf)
local byType, groups = {}, {}
for _, building in ipairs(inventory or {}) do
for _, item in ipairs(building.entries or {}) do
local group = byType[item.fillType]
if group == nil then
group = {fillType = item.fillType, title = tostring(titleOf(item.fillType) or item.fillType),
level = 0, capacity = 0, count = 0, isOutput = false, buildings = {}}
byType[item.fillType] = group
table.insert(groups, group)
end
group.level = group.level + (item.level or 0)
group.capacity = group.capacity + (item.capacity or 0)
group.count = (group.count ~= nil and item.count ~= nil) and group.count + item.count or nil
group.isOutput = group.isOutput or item.isOutput == true
-- skur uden pladsgrænse har kapacitet 0: så er varens samlede procent ukendt
if (item.capacity or 0) <= 0 and (item.level or 0) > 0 then group.unbounded = true end
local percent = percentOf(item.level, item.capacity)
if item.count ~= nil and (item.slots or 0) > 0 then percent = percentOf(item.count, item.slots) end
table.insert(group.buildings, {name = building.name, placeable = building.placeable, level = item.level or 0,
capacity = item.capacity or 0, percent = percent, count = item.count, slots = item.slots, isOutput = item.isOutput == true})
end
end
for _, group in ipairs(groups) do
group.percent = not group.unbounded and percentOf(group.level, group.capacity) or nil
table.sort(group.buildings, function(a, b)
if a.level ~= b.level then return a.level > b.level end
return tostring(a.name) < tostring(b.name)
end)
end
table.sort(groups, function(a, b)
if a.title ~= b.title then return a.title < b.title end
return a.fillType < b.fillType
end)
return groups
end
-- inventory -> {[fillType] = kapacitet i liter} for hele gården
function ADStorageStats.farmCapacities(inventory)
local totals = {}
for _, building in ipairs(inventory or {}) do
for _, entry in ipairs(building.entries or {}) do
totals[entry.fillType] = (totals[entry.fillType] or 0) + (entry.capacity or 0)
end
end
return totals
end
function ADStorageStats.emptyLevel(days)
if days == nil then return nil end
if days < ADStorageStats.DAYS_RED then return "red" end
if days < ADStorageStats.DAYS_YELLOW then return "yellow" end
return nil
end
-- Fuldt er kun et problem for noget der bliver produceret (fyldes løbende); en fuld lagersilo er bare lager.
function ADStorageStats.fullLevel(percent, producing)
if not producing or percent == nil then return nil end
if percent >= ADStorageStats.FULL_RED then return "red" end
if percent >= ADStorageStats.FULL_YELLOW then return "yellow" end
return nil
end
local SEVERITY_RANK = {red = 2, yellow = 1}
function ADStorageStats.worst(a, b)
return (SEVERITY_RANK[a] or 0) >= (SEVERITY_RANK[b] or 0) and a or b
end
-- Én bygnings bjælke: stald-trug der tømmes (ikke mælk/gylle) er rødt når næsten tomt; produceret
-- vare/fabriksoutput er rødt når næsten fuldt.
function ADStorageStats.buildingLevel(building, kind, isStableOutput)
local percent = building.percent
if percent == nil then return nil end
if kind == "husbandry" and not isStableOutput then
if percent <= ADStorageStats.BARN_LOW_RED then return "red" end
if percent <= ADStorageStats.BARN_LOW_YELLOW then return "yellow" end
return nil
end
return ADStorageStats.fullLevel(percent, building.isOutput == true or isStableOutput)
end
-- group fra groupByFillType; farm = {level, capacity} for varen på hele gården (nil = kun fanens tal);
-- rate = {consumption, production} pr. døgn; quote = {price, top} pr. liter.
function ADStorageStats.groupStats(group, farm, rate, quote)
local farmLevel = farm ~= nil and farm.level or group.level
local farmCapacity = farm ~= nil and farm.capacity or group.capacity
local consumption = rate ~= nil and rate.consumption or 0
local production = rate ~= nil and rate.production or 0
local net = production - consumption
local stat = {}
for key, value in pairs(group) do stat[key] = value end
stat.farmLevel, stat.farmCapacity = farmLevel, farmCapacity
stat.consumption, stat.production, stat.net = consumption, production, net
stat.days = consumption > production and farmLevel / (consumption - production) or nil
stat.daysToFull = net > 0 and (farmCapacity or 0) > 0 and math.max(0, farmCapacity - farmLevel) / net or nil
stat.producing = group.isOutput == true or net > 0
if quote ~= nil and quote.price ~= nil then
stat.valueNow = math.floor(group.level * quote.price + 0.5)
stat.valueTop = math.floor(group.level * (quote.top or quote.price) + 0.5)
stat.priceRatio = (quote.top or 0) > 0 and quote.price / quote.top or nil
end
stat.emptyLevel = ADStorageStats.emptyLevel(stat.days)
stat.fullLevel = ADStorageStats.fullLevel(group.percent, stat.producing)
stat.severity = ADStorageStats.worst(stat.emptyLevel, stat.fullLevel)
return stat
end
-- Rangering: rød tom > rød fuld > gul tom > gul fuld > sælg nu (største beløb først).
-- Kortest tid til tom / mest fyldt vinder inden for samme trin.
local function attentionEntries(stat)
local entries = {}
if stat.emptyLevel ~= nil then
local base = stat.emptyLevel == "red" and 4000 or 2000
table.insert(entries, {kind = "empty", level = stat.emptyLevel, stat = stat, score = base - math.min(999, (stat.days or 0) * 100)})
end
if stat.fullLevel ~= nil then
local base = stat.fullLevel == "red" and 3000 or 1000
table.insert(entries, {kind = "full", level = stat.fullLevel, stat = stat, score = base + (stat.percent or 0)})
end
-- sælg kun det gården ikke selv æder af
if stat.priceRatio ~= nil and stat.priceRatio >= ADStorageStats.SELL_RATIO and (stat.valueNow or 0) >= ADStorageStats.SELL_MIN_VALUE
and (stat.net or 0) >= 0 then
table.insert(entries, {kind = "sell", level = "green", stat = stat, score = math.min(999, stat.valueNow / 1e6)})
end
return entries
end
function ADStorageStats.attentionList(stats, max)
local list = {}
for _, stat in ipairs(stats or {}) do
for _, entry in ipairs(attentionEntries(stat)) do table.insert(list, entry) end
end
return ADStorageStats.rankAttention(list, max)
end
-- Kæde-forslag ligger ikke på en vare (ingen stat): navnet til den stabile rækkefølge tages fra teksten.
local function entryName(entry)
return tostring(entry.stat ~= nil and entry.stat.title or entry.text)
end
function ADStorageStats.rankAttention(list, max)
table.sort(list, function(a, b)
if a.score ~= b.score then return a.score > b.score end
return entryName(a) < entryName(b)
end)
local result = {}
for index = 1, math.min(#list, max or #list) do result[index] = list[index] end
return result
end
-- "Kræver handling" fra kæden (ADChain.last): ÉT gult punkt "Kæde: N forslag" (klik -> fanen Kæder), så
-- forslagene ikke fylder listen. Rangeres under alle røde punkter (>= 3000), øverst blandt de gule, over "sælg nu".
ADStorageStats.CHAIN_SCORE = 2000
-- Manglende led: ALLE kædeproblemer i ÉN rød post ("Kæder: N manglende led", klik -> fanen Kæder med kæderne
-- foldet ud), så en gård med mange kæder (fx fermenteringssiloer) ikke fylder "Kræver handling". N = unikke
-- (kæde, vare). Score CHAIN_PROBLEM_SCORE + alvor (0-99): under "rød fuld" (3000-3100), så lagerets røde poster
-- altid vises først, og over de gule (<= 2000). Alvor = manglende andel af behovet; kredsløb = 99.
ADStorageStats.CHAIN_PROBLEM_SCORE = 2900
local function severity(problems)
local missing, need = 0, 0
for _, problem in ipairs(problems) do
if problem.kind == "cycle" then return 99 end
missing, need = missing + (problem.detail or 0), need + (problem.need or 0)
end
if need <= 0 then return 99 end
return math.floor(99 * math.min(1, missing / need))
end
local function chainProblemEntries(last)
local known = {}
for _, root in ipairs(last.chain ~= nil and last.chain.roots or {}) do known[root.key] = true end
local problems, seen, rootKeys, rootSeen = {}, {}, {}, {}
for _, problem in ipairs(last.chain ~= nil and last.chain.problems or {}) do
if problem.rootKey ~= nil and known[problem.rootKey] then
table.insert(problems, problem)
seen[tostring(problem.rootKey) .. "|" .. tostring(problem.fillType)] = true
if not rootSeen[problem.rootKey] then
rootSeen[problem.rootKey] = true
table.insert(rootKeys, problem.rootKey)
end
end
end
if #problems == 0 then return {} end
local count = 0
for _ in pairs(seen) do count = count + 1 end
table.sort(rootKeys, function(a, b) return tostring(a) < tostring(b) end)
return {{kind = "chainProblem", level = "red", score = ADStorageStats.CHAIN_PROBLEM_SCORE + severity(problems), count = count,
rootKeys = rootKeys, text = string.format("Kæder: %d", count)}}
end
-- "Slukket men nødvendig" (ADChain.last.dependency.offNeeded): højst MAX_DEPENDENCY_ENTRIES post (den mest hastende
-- fabrik; more = antal skjulte, vist som "+k"). Score DEPENDENCY_SCORE + hast (0-99, færre lagerdage = højere;
-- ukendt = 50): over "rød fuld" (3000-3100), under "rød tom" (3900-4000).
ADStorageStats.DEPENDENCY_SCORE = 3700
ADStorageStats.MAX_DEPENDENCY_ENTRIES = 1
local function urgency(days)
if days == nil then return 50 end
if days == math.huge then return 0 end
return math.max(0, 99 - math.min(99, math.floor(days)))
end
function ADStorageStats.dependencyEntries(last)
local entries = {}
for _, item in ipairs(last ~= nil and last.dependency ~= nil and last.dependency.offNeeded or {}) do
local name = tostring(item.buildingName or item.building or "?")
table.insert(entries, {kind = "dependencyOff", level = "red", score = ADStorageStats.DEPENDENCY_SCORE + urgency(item.days),
building = item.building, buildingName = name, fillType = item.fillType, onlyChain = item.onlyChain == true, text = name})
end
entries = ADStorageStats.rankAttention(entries)
local result = {}
for index = 1, math.min(#entries, ADStorageStats.MAX_DEPENDENCY_ENTRIES) do result[index] = entries[index] end
if #entries > #result then result[#result].more = #entries - #result end
return result
end
function ADStorageStats.chainEntries(last)
if last == nil then return {} end
local result = chainProblemEntries(last)
for _, entry in ipairs(ADStorageStats.dependencyEntries(last)) do table.insert(result, entry) end
local count = 0
for _, action in ipairs(last.actions or {}) do
if action.mode == "suggest" then count = count + 1 end
end
if count > 0 then
table.insert(result, {kind = "chainSuggest", level = "yellow", score = ADStorageStats.CHAIN_SCORE, count = count,
text = string.format("Kæde: %d", count)})
end
return result
end
-- Varepunkterne (allerede rangeret) + kædens punkter, samme loft.
function ADStorageStats.mergeAttention(attention, chainEntries, max)
local list = {}
for _, entry in ipairs(attention or {}) do table.insert(list, entry) end
for _, entry in ipairs(chainEntries or {}) do table.insert(list, entry) end
return ADStorageStats.rankAttention(list, max)
end
function ADStorageStats.kpis(stats)
local kpi = {valueNow = 0, gain = 0, emptyCount = 0, fullCount = 0}
for _, stat in ipairs(stats or {}) do
kpi.valueNow = kpi.valueNow + (stat.valueNow or 0)
kpi.gain = kpi.gain + math.max(0, (stat.valueTop or 0) - (stat.valueNow or 0))
if stat.emptyLevel ~= nil then kpi.emptyCount = kpi.emptyCount + 1 end
if stat.fullLevel ~= nil then kpi.fullCount = kpi.fullCount + 1 end
end
return kpi
end
-- procent -> bjælkens brøk i [0,1]
function ADStorageStats.fraction(percent)
if percent == nil then return 0 end
return math.max(0, math.min(1, percent / 100))
end
-- ---- "kræver handling" -> flow ----
-- Bygninger der BRUGER varen (fabrik-input, stald-foder) — silo/objektlager lagrer kun. -> {{name, placeable, level}}
function ADStorageStats.consumersOf(inventory, fillType)
local result = {}
for _, building in ipairs(inventory or {}) do
if building.kind == "factory" or building.kind == "husbandry" then
for _, entry in ipairs(building.entries or {}) do
if entry.fillType == fillType and not entry.isOutput then
table.insert(result, {name = building.name, placeable = building.placeable, level = entry.level or 0})
end
end
end
end
return result
end
-- Hjælper et flow? Et flow flytter kun varer: det nytter når varen ligger andre steder end hos forbrugerne.
-- s = {farmLevel, consumerLevel, perDay, consumerCount, existingRunId} -> "open" | "flow" | "produce" | "none"
ADStorageStats.MIN_FLOW_LITERS = 5000
ADStorageStats.MIN_FLOW_DAY_SHARE = 0.25 -- mindst en kvart dags forbrug skal kunne flyttes
function ADStorageStats.flowAdvice(s)
if (s.consumerCount or 0) == 0 then return "none" end
if s.existingRunId ~= nil then return "open" end
local elsewhere = (s.farmLevel or 0) - (s.consumerLevel or 0)
local needed = math.max(ADStorageStats.MIN_FLOW_LITERS, (s.perDay or 0) * ADStorageStats.MIN_FLOW_DAY_SHARE)
return elsewhere >= needed and "flow" or "produce"
end
-- "Sælg nu" -> udkørsels-flow: s = {sourceCount, existingRunId} -> "open" | "flow" | "none"
function ADStorageStats.sellAdvice(s)
if (s.sourceCount or 0) == 0 then return "none" end
if s.existingRunId ~= nil then return "open" end
return "flow"
end