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