359 lines
16 KiB
Lua
359 lines
16 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 pr. afgrødeforslag spilleren skal tage stilling til.
|
|
-- Rangeres over "sælg nu" (score < 1000) og under røde punkter (>= 3000).
|
|
ADStorageStats.CHAIN_SCORE = 1000
|
|
|
|
function ADStorageStats.chainEntries(last)
|
|
local entries = {}
|
|
local titles = last ~= nil and last.titles or {}
|
|
local function title(name) return tostring((titles or {})[name] or name) end
|
|
for _, action in ipairs(last ~= nil and last.actions or {}) do
|
|
if action.mode == "suggest" then
|
|
local from = (action.from ~= nil and action.from ~= "") and title(action.from) or "-"
|
|
table.insert(entries, {kind = "chainSuggest", level = "yellow", score = ADStorageStats.CHAIN_SCORE, fieldId = action.fieldId,
|
|
from = from, to = title(action.to), hectares = action.hectares,
|
|
text = string.format("F%s %s → %s", tostring(action.fieldId), from, title(action.to))})
|
|
end
|
|
end
|
|
return entries
|
|
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
|