Files
fs25-adsmartpickup/FS25_ADSmartPickup/adChainView.lua
T
masterdraco 131b1ad10c fix(kæde): stald-mangel forklares rigtigt og beskeder nævner kæden
Køb af en rest efter gårdens stalde giver nu spu_chain_problem_barn (køb resten
eller flere dyr) i stedet for 'byg en fabrik'. Engangsbeskeden får kædens navn
foran, så to kæder der mangler samme vare kan skelnes.
2026-09-30 14:42:53 +02:00

384 lines
19 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- AD Chain View (ren logik)
-- Rækkerne til fanen Kæder ud fra ADChain.last. Hver rod er foldet sammen som standard (én linje med samlet
-- status + antal forslag); foldet ud vises varerne rekursivt (kilde, behov pr. måned, status, lager). Forslag
-- vises altid (under roden eller under afgrøden). Pausede rødder (som modellen ikke længere ser som rødder)
-- får en grå linje med [Genoptag]. Nederst frie marker + antal automatiske skift (+ "marker scannes endnu"). Ingen spiladgang:
-- titleOf(navn) -> titel, fmt(liter) -> kompakt tal, textOf(l10n-nøgle) -> tekst gives udefra.
-- Manglende led (Task 10): foldet ud står problemteksten (problemText) som en rød tekstrække ("problem", ingen celler)
-- lige under varen — teksten er for lang til en celle og ombrydes af fanen.
-- Række: {kind="header"|"root"|"node"|"suggest"|"auto"|"info"|"empty"|"problem", indent, level, cells={c1..c5}, text,
-- actions={[celle]={kind="approve"|"dismiss"|"pause"|"resume", fieldId|rootKey}}, toggle=rootKey, fillType}
ADChainView = {}
ADChainView.INDENT = " "
-- Tegnbudget pr. celle i flowListPrefab (gui/SmartPickupFrame.xml: textMaxWidth 192/162/62/244/92 px).
-- Antaget ~11,5 px pr. tegn (spuInfoText: 800 px ~ 70 tegn), fed ~12,5 px; c3 har kun tal (~10 px pr. tegn).
ADChainView.CELL_CHARS = {15, 14, 6, 21, 8}
ADChainView.MAX_SOURCE_NAMES = 2
-- Tegnbudget for en tekstlinje (addTextRow/spuInfoText: 800 px ~ 70 tegn), fx linjen med frie marker.
ADChainView.TEXT_ROW_CHARS = 70
-- "stramt": lager for en mark-afgrøde under så mange måneders behov -> gul
ADChainView.TIGHT_MONTHS = 3
-- nøgler rækkerne bruger (til l10n-testen)
ADChainView.TEXT_KEYS = {"spu_chain_empty", "spu_chain_approve", "spu_chain_dismiss", "spu_chain_auto",
"spu_chain_flowOk", "spu_chain_flowMissing", "spu_chain_flowPallet", "spu_chain_flowNone", "spu_chain_flowStopped", "spu_chain_bottleneck",
"spu_chain_buy", "spu_chain_cycle", "spu_chain_fieldShort", "spu_chain_tight", "spu_chain_noField", "spu_chain_buySource",
"spu_chain_pause", "spu_chain_resume", "spu_chain_scanning", "spu_chain_rootOk", "spu_chain_rootError", "spu_chain_rootWaiting",
"spu_chain_rootPaused", "spu_chain_rootSummary", "spu_chain_free", "spu_chain_switches", "spu_chain_gone", "spu_chain_col_item",
"spu_chain_col_source", "spu_chain_col_perMonth", "spu_chain_col_status", "spu_chain_col_stock",
"spu_chain_problem_buy", "spu_chain_problem_bottleneck", "spu_chain_problem_off", "spu_chain_problem_cycle", "spu_chain_problem_barn"}
local FLOW_KEYS = {ok = "spu_chain_flowOk", missing = "spu_chain_flowNone", stopped = "spu_chain_flowStopped", noVehicle = "spu_chain_flowMissing",
pallet = "spu_chain_flowPallet"}
local ROOT_STATUS = {running = "spu_chain_rootOk", error = "spu_chain_rootError", waiting = "spu_chain_rootWaiting", stopped = "spu_chain_rootPaused"}
local function identity(value) return tostring(value) end
-- samme som SmartPickupFrame.formatCompact (standard-fmt når ingen gives, fx i beskeden fra kædens tick)
function ADChainView.compact(liters)
liters = math.floor((liters or 0) + 0.5)
if liters >= 1000000 then return string.format("%.1fM", liters / 1000000)
elseif liters >= 1000 then return string.format("%.1fk", liters / 1000) end
return string.format("%d", liters)
end
-- ---- UTF-8-sikker længde/afkortning (æøå og → er flere bytes) ----
function ADChainView.textLength(text)
local count = 0
for _ in tostring(text):gmatch("[^\128-\191]") do count = count + 1 end
return count
end
local function utf8Prefix(text, chars)
local result, count = {}, 0
for char in text:gmatch("[^\128-\191][\128-\191]*") do
if count >= chars then break end
table.insert(result, char)
count = count + 1
end
return table.concat(result)
end
-- Afkort til celle-budgettet med ".." til sidst.
function ADChainView.fit(text, chars)
text = tostring(text or "")
if ADChainView.textLength(text) <= chars then return text end
return utf8Prefix(text, math.max(0, chars - 2)) .. ".."
end
local function fitCells(cells)
local result = {}
for index = 1, 5 do result[index] = ADChainView.fit(cells[index] or "", ADChainView.CELL_CHARS[index]) end
return result
end
local function haText(ha)
return string.format("%.0f", ha or 0)
end
local function plannedFruit(field)
if field.targetFruit ~= nil and field.targetFruit ~= "" then return field.targetFruit end
return field.fruit
end
local function edgeKey(node)
return tostring(node.consumer) .. "|" .. tostring(node.fillType)
end
local function shortageByFruit(last)
local result = {}
for _, shortage in ipairs(last.shortages or {}) do result[shortage.fruit] = shortage end
return result
end
local function stockOf(last, fruit)
local stock = last.data ~= nil and last.data.stock or nil
return stock ~= nil and stock[fruit] or nil
end
-- Mark-afgrøde uden mangel men med lager under TIGHT_MONTHS måneders behov.
local function isTight(node, last, short)
if node.source ~= "field" or node.fruit == nil or short[node.fruit] ~= nil then return false end
return (stockOf(last, node.fruit) or 0) < ADChainView.TIGHT_MONTHS * (node.perMonth or 0)
end
local function eachNode(nodes, fn, depth)
for _, node in ipairs(nodes or {}) do
fn(node, depth or 1)
eachNode(node.children, fn, (depth or 1) + 1)
end
end
function ADChainView.rootLevel(root, last)
last = last or {}
if (last.paused or {})[root.key] then return "stopped" end
for _, problem in ipairs((last.chain or {}).problems or {}) do
if problem.rootKey == root.key then return "error" end
end
local short, flowStatus = shortageByFruit(last), last.flowStatus or {}
local hasShortage, hasWaiting = false, false
eachNode(root.children, function(node)
if node.fruit ~= nil and short[node.fruit] ~= nil then hasShortage = true end
if (node.shortPerMonth or 0) > 0 then hasShortage = true end
local status = node.consumer ~= nil and flowStatus[edgeKey(node)] or nil
if (status ~= nil and status ~= "ok") or isTight(node, last, short) then hasWaiting = true end
end)
if hasShortage then return "error" end
if hasWaiting then return "waiting" end
return "running"
end
-- Højst MAX_SOURCE_NAMES navne + " +k".
local function joinCapped(names)
local shown = {}
for index = 1, math.min(#names, ADChainView.MAX_SOURCE_NAMES) do shown[index] = names[index] end
local text = table.concat(shown, ", ")
if #names > #shown then text = text .. " +" .. tostring(#names - #shown) end
return text
end
-- Opskrifter (alle, også slukkede) der laver varen, i datarækkefølge.
local function producersOf(last, fillType)
local list = {}
for _, recipe in ipairs(last.data ~= nil and last.data.recipes or {}) do
if ((recipe.outputs or {})[fillType] or 0) > 0 then table.insert(list, recipe) end
end
return list
end
-- Årsag i klart sprog med råd for et problem fra kædemodellen ({kind="buy"|"bottleneck"|"cycle", fillType, rootKey,
-- detail = manglende l/md, need = behov l/md}). Køb af en rest efter gårdens stalde -> stald-tekst. En producent spilleren selv har slukket (last.userOff, stadig
-- slukket) slår igennem som "slukket af dig" — det er den reelle årsag, uanset om modellen så køb eller flaskehals.
function ADChainView.problemText(problem, last, textOf, titleOf, fmt)
last, textOf, fmt = last or {}, textOf or identity, fmt or ADChainView.compact
titleOf = titleOf or function(name) return (last.titles or {})[name] or tostring(name) end
local item = titleOf(problem.fillType)
if problem.kind == "cycle" then return string.format(textOf("spu_chain_problem_cycle"), item) end
local userOff, names, seen = last.userOff or {}, {}, {}
for _, recipe in ipairs(producersOf(last, problem.fillType)) do
if userOff[recipe.key] and not recipe.enabled then
return string.format(textOf("spu_chain_problem_off"), tostring(recipe.buildingName), titleOf(recipe.name))
end
local name = tostring(recipe.buildingName)
if (recipe.cyclesPerMonth or 0) > 0 and not seen[name] then
seen[name] = true
table.insert(names, name)
end
end
if problem.kind == "bottleneck" and #names > 0 then
local need = problem.need or problem.detail or 0
local made = math.max(0, need - (problem.detail or 0))
return string.format(textOf("spu_chain_problem_bottleneck"), joinCapped(names), fmt(math.floor(made + 0.5)), fmt(math.floor(need + 0.5)))
end
-- modellen melder også "køb" for resten, når gårdens stalde leverer for lidt: så er rådet ikke "byg en fabrik"
for _, herd in ipairs(last.data ~= nil and last.data.husbandry or {}) do
if ((herd.outputs or {})[problem.fillType] or 0) > 0 then
return string.format(textOf("spu_chain_problem_barn"), item)
end
end
return string.format(textOf("spu_chain_problem_buy"), item)
end
local function sourceText(node, last, textOf)
local names = {}
if node.source == "production" then
for _, producer in ipairs(node.producers or {}) do table.insert(names, tostring(producer.buildingName)) end
elseif node.source == "husbandry" then
for _, herd in ipairs(node.herds or {}) do table.insert(names, tostring(herd.buildingName)) end
elseif node.source == "field" then
for _, field in ipairs(last.fields or {}) do
if plannedFruit(field) == node.fruit then table.insert(names, "F" .. tostring(field.id)) end
end
if #names == 0 then return textOf("spu_chain_noField") end
elseif node.source == "buy" then
return textOf("spu_chain_buySource")
else
return "-"
end
return joinCapped(names)
end
-- Nodens status (tekst, niveau): kredsløb/flaskehals/køb/mangel (rød) > flow ikke ok (gul) > stramt lager (gul) > flow ok.
local function nodeStatus(node, last, fmt, textOf, short)
if node.source == "cycle" then return textOf("spu_chain_cycle"), "error" end
if (node.shortPerMonth or 0) > 0 then
local key = node.source == "production" and "spu_chain_bottleneck" or "spu_chain_buy"
return string.format(textOf(key), fmt(node.shortPerMonth)), "error"
end
local shortage = node.fruit ~= nil and short[node.fruit] or nil
if shortage ~= nil then return string.format(textOf("spu_chain_fieldShort"), haText(shortage.missingHa)), "error" end
local status = node.consumer ~= nil and (last.flowStatus or {})[edgeKey(node)] or nil
if status ~= nil and status ~= "ok" then return textOf(FLOW_KEYS[status] or "spu_chain_flowNone"), "waiting" end
if isTight(node, last, short) then return textOf("spu_chain_tight"), "waiting" end
if status == "ok" then return textOf("spu_chain_flowOk"), "running" end
return "", "running"
end
local function joinText(cells)
local parts = {}
for _, cell in ipairs(cells) do
if cell ~= nil and cell ~= "" then table.insert(parts, cell) end
end
return table.concat(parts, " · ")
end
local function makeRow(kind, indent, level, cells, actions, toggle)
local fitted = fitCells(cells)
return {kind = kind, indent = indent, level = level, cells = fitted, actions = actions or {}, toggle = toggle, text = joinText(fitted)}
end
local function suggestRow(action, indent, titleOf, textOf)
local pad = string.rep(ADChainView.INDENT, indent)
local from = (action.from ~= nil and action.from ~= "") and titleOf(action.from) or "-"
local cells = {pad .. "F" .. tostring(action.fieldId) .. " " .. from, "→ " .. titleOf(action.to), haText(action.hectares) .. " ha"}
if action.mode == "suggest" then
cells[4], cells[5] = textOf("spu_chain_approve"), textOf("spu_chain_dismiss")
return makeRow("suggest", indent, "waiting", cells,
{[4] = {kind = "approve", fieldId = action.fieldId}, [5] = {kind = "dismiss", fieldId = action.fieldId}})
end
cells[4] = textOf("spu_chain_auto")
return makeRow("auto", indent, "running", cells)
end
-- Handlinger for de afgrøder (i rækkefølge) der hentes fra mark under roden.
local function rootFruits(root)
local fruits, seen = {}, {}
eachNode(root.children, function(node)
if node.source == "field" and node.fruit ~= nil and not seen[node.fruit] then
seen[node.fruit] = true
table.insert(fruits, node.fruit)
end
end)
return fruits
end
local function actionsFor(last, fruit, modes)
local list = {}
for _, action in ipairs(last.actions or {}) do
if action.to == fruit and modes[action.mode] then table.insert(list, action) end
end
return list
end
local function rootRows(rows, root, last, ctx, isExpanded)
local level = ADChainView.rootLevel(root, last)
local paused = (last.paused or {})[root.key] == true
local suggestions = {}
for _, fruit in ipairs(rootFruits(root)) do
for _, action in ipairs(actionsFor(last, fruit, {suggest = true})) do table.insert(suggestions, action) end
end
local status = ctx.textOf(ROOT_STATUS[level])
if #suggestions > 0 then status = string.format(ctx.textOf("spu_chain_rootSummary"), status, #suggestions) end
local marker = (#(root.children or {}) > 0) and (isExpanded and "– " or "+ ") or ""
table.insert(rows, makeRow("root", 0, level, {marker .. ctx.titleOf(root.name), tostring(root.buildingName or ""), "", status,
ctx.textOf(paused and "spu_chain_resume" or "spu_chain_pause")},
{[5] = {kind = paused and "resume" or "pause", rootKey = root.key}}, root.key))
if not isExpanded then
for _, action in ipairs(suggestions) do table.insert(rows, suggestRow(action, 1, ctx.titleOf, ctx.textOf)) end
return
end
local shown, problems, told = {}, {}, {}
for _, problem in ipairs((last.chain or {}).problems or {}) do
if problem.rootKey == root.key then table.insert(problems, problem) end
end
local function problemRows(fillType, indent)
for _, problem in ipairs(problems) do
local key = tostring(problem.fillType) .. "|" .. tostring(problem.kind)
if (fillType == nil or problem.fillType == fillType) and not told[key] then
told[key] = true
table.insert(rows, {kind = "problem", indent = indent, level = "error",
text = string.rep(ADChainView.INDENT, indent) .. ADChainView.problemText(problem, last, ctx.textOf, ctx.titleOf, ctx.fmt)})
end
end
end
eachNode(root.children, function(node, depth)
local text, nodeLevel = nodeStatus(node, last, ctx.fmt, ctx.textOf, ctx.short)
local stock = node.source == "field" and stockOf(last, node.fruit) or nil
local row = makeRow("node", depth, nodeLevel, {string.rep(ADChainView.INDENT, depth) .. ctx.titleOf(node.fillType),
sourceText(node, last, ctx.textOf), ctx.fmt(node.perMonth or 0), text, stock ~= nil and ctx.fmt(stock) or ""})
row.fillType = node.fillType
table.insert(rows, row)
problemRows(node.fillType, depth + 1)
if node.source == "field" and node.fruit ~= nil and not shown[node.fruit] then
shown[node.fruit] = true
for _, action in ipairs(actionsFor(last, node.fruit, {suggest = true, auto = true})) do
table.insert(rows, suggestRow(action, depth + 1, ctx.titleOf, ctx.textOf))
end
end
end)
-- problemer uden en vist vare (bør ikke ske) står sidst under roden
problemRows(nil, 1)
end
-- Pausede rødder: modelRecipes markerer dem paused, så modellen ser dem ikke som rødder -> grå linje med [Genoptag].
local function pausedRows(rows, last, ctx)
local present, keys = {}, {}
for _, root in ipairs(last.chain ~= nil and last.chain.roots or {}) do present[root.key] = true end
for key in pairs(last.paused or {}) do
if not present[key] then table.insert(keys, key) end
end
table.sort(keys, function(a, b) return tostring(a) < tostring(b) end)
for _, key in ipairs(keys) do
local recipe
for _, candidate in ipairs(last.data ~= nil and last.data.recipes or {}) do
if candidate.key == key then recipe = candidate end
end
local name = recipe ~= nil and recipe.name ~= nil and ctx.titleOf(recipe.name) or tostring(key)
table.insert(rows, makeRow("root", 0, "stopped", {name, recipe ~= nil and tostring(recipe.buildingName or "") or "", "",
ctx.textOf("spu_chain_rootPaused"), ctx.textOf("spu_chain_resume")}, {[5] = {kind = "resume", rootKey = key}}))
end
return #keys
end
-- "Marker fri til kæden: F1, F2 +k" — så mange navne som tekstlinjen kan rumme, resten talt.
function ADChainView.freeText(names, textOf)
local format = textOf("spu_chain_free")
if #names == 0 then return string.format(format, "-") end
for count = #names, 1, -1 do
local shown = {}
for index = 1, count do shown[index] = names[index] end
local list = table.concat(shown, ", ")
if count < #names then list = list .. " +" .. tostring(#names - count) end
local text = string.format(format, list)
if ADChainView.textLength(text) <= ADChainView.TEXT_ROW_CHARS then return text end
end
return ADChainView.fit(string.format(format, "+" .. tostring(#names)), ADChainView.TEXT_ROW_CHARS)
end
local function footerRows(rows, last, textOf)
local free = {}
for _, field in ipairs(last.fields or {}) do
if field.chainFree then table.insert(free, field.id) end
end
table.sort(free)
local names = {}
for _, id in ipairs(free) do table.insert(names, "F" .. tostring(id)) end
table.insert(rows, {kind = "info", indent = 0, text = ADChainView.freeText(names, textOf)})
table.insert(rows, {kind = "info", indent = 0, text = string.format(textOf("spu_chain_switches"), last.switchCount or 0)})
if last.fieldsIncomplete then table.insert(rows, {kind = "info", indent = 0, text = textOf("spu_chain_scanning")}) end
end
-- expanded = {[rodnøgle] = true} for udfoldede rødder (alle andre er foldet sammen).
function ADChainView.rows(last, titleOf, fmt, textOf, expanded)
textOf, fmt, expanded = textOf or identity, fmt or identity, expanded or {}
local titles = last ~= nil and last.titles or {}
titleOf = titleOf or function(name) return (titles or {})[name] or tostring(name) end
local rows = {}
local roots = last ~= nil and last.chain ~= nil and last.chain.roots or {}
local hasPaused = last ~= nil and next(last.paused or {}) ~= nil
if #roots == 0 and not hasPaused then
table.insert(rows, {kind = "empty", indent = 0, text = textOf("spu_chain_empty")})
end
if last == nil then return rows end
if #roots > 0 or hasPaused then
table.insert(rows, makeRow("header", 0, nil, {textOf("spu_chain_col_item"), textOf("spu_chain_col_source"),
textOf("spu_chain_col_perMonth"), textOf("spu_chain_col_status"), textOf("spu_chain_col_stock")}))
end
local ctx = {titleOf = titleOf, fmt = fmt, textOf = textOf, short = shortageByFruit(last)}
for _, root in ipairs(roots) do rootRows(rows, root, last, ctx, expanded[root.key] == true) end
pausedRows(rows, last, ctx)
footerRows(rows, last, textOf)
return rows
end