Files
fs25-adsmartpickup/FS25_ADSmartPickup/adStorageStats.lua
T
masterdraco 19a10ab67c feat(kæde): manglende led forklares i Kræver handling, fanen og som besked
problemText giver årsag og råd for køb/flaskehals/kredsløb og 'slukket af dig'
(userOff-kopi på ADChain.last); modellens problemer får need. Fanen Kæder viser
teksten som rød tekstrække under varen (foldet ud). Kræver handling får én rød
post pr. kæde (højst 2, score 3500+alvor mellem rød tom og rød fuld); klik åbner
fanen med kæden foldet ud. Engangsbesked pr. nyt led (rod|vare|art) kun fra
tick, ikke i opstartsfristen eller på delvise data/ufærdig markscanning.
Manualer og CHANGELOG opdateret.
2026-09-30 14:40:29 +02:00

414 lines
18 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: én rød post pr. kæde ("Brød mangler et led: Mel"), højst MAX_CHAIN_PROBLEMS (resten står i
-- fanen Kæder; den sidste viste post får more = antal skjulte). Score CHAIN_PROBLEM_SCORE + alvor (0-99): under
-- "rød tom" (3900-4000: løber tør inden et døgn), over "rød fuld" (3000-3100). Alvor = manglende andel af behovet;
-- kredsløb (kan ikke beregnes) = 99.
ADStorageStats.CHAIN_PROBLEM_SCORE = 3500
ADStorageStats.MAX_CHAIN_PROBLEMS = 2
ADStorageStats.MAX_CHAIN_PROBLEM_ITEMS = 2
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 problemEntry(root, problems, titleOf)
local items, seen = {}, {}
for _, problem in ipairs(problems) do
local title = titleOf(problem.fillType)
if not seen[title] then
seen[title] = true
table.insert(items, title)
end
end
local shown = {}
for index = 1, math.min(#items, ADStorageStats.MAX_CHAIN_PROBLEM_ITEMS) do shown[index] = items[index] end
local itemsText = table.concat(shown, ", ")
if #items > #shown then itemsText = itemsText .. " +" .. tostring(#items - #shown) end
local rootTitle = titleOf(root.name)
return {kind = "chainProblem", level = "red", score = ADStorageStats.CHAIN_PROBLEM_SCORE + severity(problems), rootKey = root.key,
rootTitle = rootTitle, itemsText = itemsText, text = rootTitle .. ": " .. itemsText}
end
local function chainProblemEntries(last)
local titles = last.titles or {}
local titleOf = function(name) return tostring(titles[name] or name) end
local byRoot = {}
for _, problem in ipairs(last.chain ~= nil and last.chain.problems or {}) do
if problem.rootKey ~= nil then
byRoot[problem.rootKey] = byRoot[problem.rootKey] or {}
table.insert(byRoot[problem.rootKey], problem)
end
end
local entries = {}
for _, root in ipairs(last.chain ~= nil and last.chain.roots or {}) do
if byRoot[root.key] ~= nil then table.insert(entries, problemEntry(root, byRoot[root.key], titleOf)) end
end
entries = ADStorageStats.rankAttention(entries)
local result = {}
for index = 1, math.min(#entries, ADStorageStats.MAX_CHAIN_PROBLEMS) 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)
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