-- 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 = {22, 20, 7, 28, 10} -- ca. tegn pr. celle (c1 192 px fed, c2 162, c3 62, c4 244, c5 92 fed) 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_finalFarm", "spu_chain_finalSell", "spu_chain_finalKeep", "spu_chain_suggestHeader", "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_fromFields", "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 fruit ~= nil and stock[fruit] or nil end -- Lager for en mark-node: afgrødens lager, eller varens eget lager for en markvare uden afgrøde (hø, hakkelse …). local function fieldStockOf(last, node) if node.fruit ~= nil then return stockOf(last, node.fruit) end local byFill = last.data ~= nil and last.data.stockByFill or nil return byFill ~= nil and byFill[node.fillType] 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 -- markvare uden egen afgrøde (hø, hakkelse …): ingen afgrødeplan, bare "fra marker" if node.fruit == nil then return textOf("spu_chain_fromFields") end 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 -- Alle forslag (mode "suggest") én gang pr. mark, efter marknummer (in-game 30/9: samme forslag stod under -- hver kæde der bruger afgrøden, spredt mellem kæderne). function ADChainView.suggestionList(last) local byField, list = {}, {} for _, action in ipairs((last or {}).actions or {}) do if action.mode == "suggest" and action.fieldId ~= nil and not byField[action.fieldId] then byField[action.fieldId] = true table.insert(list, action) end end table.sort(list, function(a, b) return (tonumber(a.fieldId) or 0) < (tonumber(b.fieldId) or 0) end) return list end -- Kæder efter status (fejl/mangel, venter, dækket, pause), så bygning, så produkt. local LEVEL_RANK = {error = 1, waiting = 2, running = 3, stopped = 4} function ADChainView.sortRoots(roots, last) local keyed = {} for index, root in ipairs(roots or {}) do table.insert(keyed, {root = root, rank = LEVEL_RANK[ADChainView.rootLevel(root, last)] or 5, building = tostring(root.buildingName or ""), name = tostring(root.name or ""), index = index}) end table.sort(keyed, function(a, b) if a.rank ~= b.rank then return a.rank < b.rank end if a.building ~= b.building then return a.building < b.building end if a.name ~= b.name then return a.name < b.name end return a.index < b.index end) local sorted = {} for _, item in ipairs(keyed) do table.insert(sorted, item.root) end return sorted 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 "" -- slutproduktet: "Sælg straks" (standard) eller "Behold" — klik på feltet skifter -- varer gården selv bruger (last.sellable uden roden) sælges aldrig: "Gård" uden klik local keep = (last.finalKeep or {})[root.key] == true local sellable = last.sellable == nil or last.sellable[root.key] == true local finalText = not sellable and "spu_chain_finalFarm" or (keep and "spu_chain_finalKeep" or "spu_chain_finalSell") local actions = {[5] = {kind = paused and "resume" or "pause", rootKey = root.key}} if sellable then actions[3] = {kind = "finalMode", rootKey = root.key, keep = not keep} end table.insert(rows, makeRow("root", 0, level, {marker .. ctx.titleOf(root.name), tostring(root.buildingName or ""), ctx.textOf(finalText), status, ctx.textOf(paused and "spu_chain_resume" or "spu_chain_pause")}, actions, root.key)) if not isExpanded then 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 fieldStockOf(last, node) 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, {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)} local suggestions = ADChainView.suggestionList(last) if #suggestions > 0 then table.insert(rows, {kind = "info", indent = 0, text = textOf("spu_chain_suggestHeader")}) for _, action in ipairs(suggestions) do table.insert(rows, suggestRow(action, 0, titleOf, textOf)) end end for _, root in ipairs(ADChainView.sortRoots(roots, last)) do rootRows(rows, root, last, ctx, expanded[root.key] == true) end pausedRows(rows, last, ctx) footerRows(rows, last, textOf) return rows end