-- AD Runs -- Rene funktioner (ingen spil-API) for "runs": navn, aflæsningsmål, varer, Wait-pulje, -- loop-tæller og tildelte traktorer. Alle funktioner returnerer en NY store — -- input røres aldrig, så GUI og controller kan holde referencer uden overraskelser. ADRuns = {} ADRuns.MAX_LOOPS = 99 -- "Kør hjem fra Wait når målet er under X %": 10..90, standard 50 (= den gamle 50 %-regel). ADRuns.DEFAULT_RETURN_BELOW_PERCENT = 50 ADRuns.MIN_RETURN_BELOW_PERCENT, ADRuns.MAX_RETURN_BELOW_PERCENT = 10, 90 ADRuns.XML_VERSION = 3 ADRuns.INGREDIENT_MODES = {auto = true, manual = true} -- Retning: Forsyning (ind til stald/fabrik) eller Udkørsel (fabrik/lager -> silo/salg). ADRuns.KINDS = {supply = true, outbound = true} -- Politik pr. vare i et udkørsels-run; "off" = varen er ikke med (fjernes fra listen). ADRuns.POLICIES = {store = true, sell = true, sellElseStore = true} ADRuns.DEFAULT_SELL_AT_PERCENT, ADRuns.MIN_SELL_AT_PERCENT, ADRuns.MAX_SELL_AT_PERCENT = 90, 50, 100 ADRuns.DEFAULT_STORE_ABOVE_PERCENT, ADRuns.MIN_STORE_ABOVE_PERCENT, ADRuns.MAX_STORE_ABOVE_PERCENT = 80, 50, 95 -- "Sælg altid over X %": kildens lager over grænsen sælges uanset pris (0 = fra). Så hober f.eks. gylle -- ved BGA'en sig ikke op, når prisen er lav. ADRuns.DEFAULT_SELL_ABOVE_PERCENT, ADRuns.MIN_SELL_ABOVE_PERCENT, ADRuns.MAX_SELL_ABOVE_PERCENT = 0, 0, 100 local function copyList(list) local copy = {} for index, value in ipairs(list or {}) do copy[index] = value end return copy end local function copyProducts(products) local copy = {} for index, product in ipairs(products or {}) do copy[index] = {fillType = product.fillType, policy = product.policy} end return copy end local function clampRange(value, default, min, max) local number = tonumber(value) or default return math.max(min, math.min(max, math.floor(number))) end -- Flow-vogne: {[traktorId] = vognId} (maskinpulje — flowet henter sin egen vogn). -- Start alle pr. flow: isRunning (traktorer kører AD), stopped (pause fra Stop alt / Stop). -- -> "queue" (start traktorerne), "resume" (kører allerede: kun pausen ophæves), "skip". -- In-game 29/9: flows på pause hvis traktorer kørte videre på en gemt AD-tur blev talt som 'kørte allerede', -- forblev stoppede, og traktoren kørte vognen hjem lige efter at have hentet den. function ADRuns.startAllAction(isRunning, stopped) if not isRunning then return "queue" end return stopped and "resume" or "skip" end function ADRuns.copyWagons(wagons) local copy = {} for tractorId, wagonId in pairs(wagons or {}) do copy[tractorId] = wagonId end return copy end function ADRuns.serializeWagons(wagons) local keys = {} for tractorId in pairs(wagons or {}) do table.insert(keys, tractorId) end table.sort(keys) local parts = {} for _, tractorId in ipairs(keys) do table.insert(parts, tractorId .. "=" .. wagons[tractorId]) end return table.concat(parts, ";") end function ADRuns.parseWagons(text) local wagons = {} for tractorId, wagonId in string.gmatch(tostring(text or ""), "([^=;]+)=([^=;]+)") do wagons[tractorId] = wagonId end return wagons end local function copyRun(run) return { id = run.id, name = run.name, kind = run.kind, sourceWayPointIds = copyList(run.sourceWayPointIds), sourceBuildingIds = copyList(run.sourceBuildingIds), targetBuildingIds = copyList(run.targetBuildingIds), storeBuildingIds = copyList(run.storeBuildingIds), products = copyProducts(run.products), sellAtPercent = run.sellAtPercent, storeAbovePercent = run.storeAbovePercent, sellAbovePercent = run.sellAbovePercent, collectRound = run.collectRound, targetWayPointId = run.targetWayPointId, targetWayPointIds = copyList(run.targetWayPointIds), ingredientMode = run.ingredientMode, ingredients = copyList(run.ingredients), waitPoolGroup = run.waitPoolGroup, loops = run.loops, returnBelowPercent = run.returnBelowPercent, vehicleIds = copyList(run.vehicleIds), stopped = run.stopped == true, wagons = ADRuns.copyWagons(run.wagons), chain = run.chain or "", } end local function copyStore(store) local runs = {} for index, run in ipairs(store.runs) do runs[index] = copyRun(run) end return {nextId = store.nextId, runs = runs} end local function clampPercent(percent) local value = tonumber(percent) or ADRuns.DEFAULT_RETURN_BELOW_PERCENT return math.max(ADRuns.MIN_RETURN_BELOW_PERCENT, math.min(ADRuns.MAX_RETURN_BELOW_PERCENT, math.floor(value))) end local function clampLoops(loops) local value = tonumber(loops) or 0 return math.max(0, math.min(ADRuns.MAX_LOOPS, math.floor(value))) end function ADRuns.newStore() return {nextId = 1, runs = {}} end function ADRuns.createRun(store, name, kind) local new = copyStore(store) local run = { id = new.nextId, name = name, kind = ADRuns.KINDS[kind] and kind or "supply", sourceWayPointIds = {}, sourceBuildingIds = {}, targetBuildingIds = {}, storeBuildingIds = {}, products = {}, sellAtPercent = ADRuns.DEFAULT_SELL_AT_PERCENT, storeAbovePercent = ADRuns.DEFAULT_STORE_ABOVE_PERCENT, sellAbovePercent = ADRuns.DEFAULT_SELL_ABOVE_PERCENT, collectRound = false, targetWayPointId = nil, targetWayPointIds = {}, ingredientMode = "auto", ingredients = {}, waitPoolGroup = "", loops = 0, returnBelowPercent = ADRuns.DEFAULT_RETURN_BELOW_PERCENT, vehicleIds = {}, chain = "", } new.nextId = new.nextId + 1 table.insert(new.runs, run) return new, run end function ADRuns.getRun(store, runId) for _, run in ipairs(store.runs) do if run.id == runId then return run end end return nil end function ADRuns.deleteRun(store, runId) local new = copyStore(store) for index, run in ipairs(new.runs) do if run.id == runId then table.remove(new.runs, index) break end end return new end -- changes: delmængde af {name, targetWayPointId, ingredientMode, ingredients, waitPoolGroup, loops}. function ADRuns.updateRun(store, runId, changes) local new = copyStore(store) local run = ADRuns.getRun(new, runId) if run == nil then return new end if changes.name ~= nil then run.name = changes.name end -- Mål: liste (flere stalde pr. run); targetWayPointId = første = "primært" (bagudkompatibelt). -- false = ryd målene (nil i changes betyder "rør ikke") if changes.targetWayPointIds ~= nil then run.targetWayPointIds = copyList(changes.targetWayPointIds) elseif changes.targetWayPointId == false then run.targetWayPointIds = {} elseif changes.targetWayPointId ~= nil then run.targetWayPointIds = {changes.targetWayPointId} end run.targetWayPointId = run.targetWayPointIds[1] if ADRuns.INGREDIENT_MODES[changes.ingredientMode] then run.ingredientMode = changes.ingredientMode end if changes.ingredients ~= nil then run.ingredients = copyList(changes.ingredients) end if changes.waitPoolGroup ~= nil then run.waitPoolGroup = changes.waitPoolGroup end if changes.loops ~= nil then run.loops = clampLoops(changes.loops) end if changes.returnBelowPercent ~= nil then run.returnBelowPercent = clampPercent(changes.returnBelowPercent) end if ADRuns.KINDS[changes.kind] then run.kind = changes.kind end if changes.sourceWayPointIds ~= nil then run.sourceWayPointIds = copyList(changes.sourceWayPointIds) end if changes.sourceBuildingIds ~= nil then run.sourceBuildingIds = copyList(changes.sourceBuildingIds) end if changes.targetBuildingIds ~= nil then run.targetBuildingIds = copyList(changes.targetBuildingIds) end if changes.storeBuildingIds ~= nil then run.storeBuildingIds = copyList(changes.storeBuildingIds) end if changes.products ~= nil then run.products = copyProducts(changes.products) end -- 0 = "Altid" (sælg uanset pris); ellers 50-100 % if changes.sellAtPercent ~= nil then run.sellAtPercent = tonumber(changes.sellAtPercent) == 0 and 0 or clampRange(changes.sellAtPercent, ADRuns.DEFAULT_SELL_AT_PERCENT, ADRuns.MIN_SELL_AT_PERCENT, ADRuns.MAX_SELL_AT_PERCENT) end if changes.storeAbovePercent ~= nil then run.storeAbovePercent = clampRange(changes.storeAbovePercent, ADRuns.DEFAULT_STORE_ABOVE_PERCENT, ADRuns.MIN_STORE_ABOVE_PERCENT, ADRuns.MAX_STORE_ABOVE_PERCENT) end if changes.sellAbovePercent ~= nil then run.sellAbovePercent = clampRange(changes.sellAbovePercent, ADRuns.DEFAULT_SELL_ABOVE_PERCENT, ADRuns.MIN_SELL_ABOVE_PERCENT, ADRuns.MAX_SELL_ABOVE_PERCENT) end if changes.collectRound ~= nil then run.collectRound = changes.collectRound == true end if changes.chain ~= nil then run.chain = tostring(changes.chain) end return new end -- ---- bygninger som kilder/mål (flow-tavlen) ---- local function toggleInList(list, value) local result, found = {}, false for _, item in ipairs(list or {}) do if item == value then found = true else table.insert(result, item) end end if not found then table.insert(result, value) end return result end function ADRuns.toggleSourceBuilding(store, runId, buildingId) local run = ADRuns.getRun(store, runId) if run == nil then return copyStore(store) end return ADRuns.updateRun(store, runId, {sourceBuildingIds = toggleInList(run.sourceBuildingIds, buildingId)}) end -- Tvunget lagermål for udkørsel: lagerture kører KUN til disse bygninger (tom = moden vælger selv). function ADRuns.toggleStoreBuilding(store, runId, buildingId) local run = ADRuns.getRun(store, runId) if run == nil then return copyStore(store) end return ADRuns.updateRun(store, runId, {storeBuildingIds = toggleInList(run.storeBuildingIds, buildingId)}) end function ADRuns.toggleTargetBuilding(store, runId, buildingId) local run = ADRuns.getRun(store, runId) if run == nil then return copyStore(store) end return ADRuns.updateRun(store, runId, {targetBuildingIds = toggleInList(run.targetBuildingIds, buildingId)}) end -- ---- udkørsel: kilder og varer ---- -- Tilføj/fjern en kilde-markør (afkrydsning i fanen Udkørsel). function ADRuns.toggleSource(store, runId, wayPointId) local run = ADRuns.getRun(store, runId) if run == nil then return copyStore(store) end local sources, found = {}, false for _, id in ipairs(run.sourceWayPointIds) do if id == wayPointId then found = true else table.insert(sources, id) end end if not found then table.insert(sources, wayPointId) end return ADRuns.updateRun(store, runId, {sourceWayPointIds = sources}) end function ADRuns.getProductPolicy(run, fillTypeName) for _, product in ipairs(run.products or {}) do if product.fillType == fillTypeName then return product.policy end end return "off" end -- policy "off" fjerner varen; ukendt politik ignoreres (store uændret). function ADRuns.setProductPolicy(store, runId, fillTypeName, policy) local run = ADRuns.getRun(store, runId) if run == nil or (policy ~= "off" and not ADRuns.POLICIES[policy]) then return copyStore(store) end local products = {} for _, product in ipairs(run.products) do if product.fillType ~= fillTypeName then table.insert(products, product) end end if policy ~= "off" then table.insert(products, {fillType = fillTypeName, policy = policy}) end return ADRuns.updateRun(store, runId, {products = products}) end -- "SUGAR:sell FORAGE:store" — navne er stabile på tværs af mods, fillType-indeks er det ikke. function ADRuns.serializeProducts(products) local parts = {} for _, product in ipairs(products or {}) do table.insert(parts, product.fillType .. ":" .. product.policy) end return table.concat(parts, " ") end function ADRuns.parseProducts(text) local products = {} for token in string.gmatch(tostring(text or ""), "%S+") do local name, policy = string.match(token, "^([^:]+):(%a+)$") if name ~= nil and ADRuns.POLICIES[policy] then table.insert(products, {fillType = name, policy = policy}) end end return products end -- Tilføj/fjern et mål (afkrydsning i fanen). function ADRuns.toggleTarget(store, runId, wayPointId) local run = ADRuns.getRun(store, runId) if run == nil then return copyStore(store) end local targets = {} local found = false for _, id in ipairs(run.targetWayPointIds) do if id == wayPointId then found = true else table.insert(targets, id) end end if not found then table.insert(targets, wayPointId) end return ADRuns.updateRun(store, runId, {targetWayPointIds = targets}) end function ADRuns.findRunByVehicle(store, vehicleId) for _, run in ipairs(store.runs) do for _, id in ipairs(run.vehicleIds) do if id == vehicleId then return run end end end return nil end function ADRuns.unassignVehicle(store, vehicleId) local new = copyStore(store) for _, run in ipairs(new.runs) do for index, id in ipairs(run.vehicleIds) do if id == vehicleId then table.remove(run.vehicleIds, index) break end end -- traktoren forlader flowet: dens huskede vogn hører ikke længere til flowet if run.wagons ~= nil then run.wagons[vehicleId] = nil end end return new end -- Traktorer der ikke findes længere (solgt/byttet) fjernes fra deres flow. exists(vehicleId) -> bool. -- Mangler ALLE flowenes traktorer, er indlæsningen nok ikke færdig/gået galt: intet fjernes. -- -> ny store, {{runName, vehicleId}} over de fjernede function ADRuns.pruneMissingVehicles(store, exists) local missing, total = {}, 0 for _, run in ipairs(store.runs) do for _, id in ipairs(run.vehicleIds) do total = total + 1 if not exists(id) then table.insert(missing, {runName = run.name, vehicleId = id}) end end end if #missing == 0 or #missing == total then return store, {} end local new = store for _, entry in ipairs(missing) do new = ADRuns.unassignVehicle(new, entry.vehicleId) end return new, missing end -- Én besked pr. berørt flow efter pruneMissingVehicles: {runName, removed, left, critical} (critical = flowet -- står uden traktorer og virker ikke). I samme rækkefølge som flowene. function ADRuns.pruneNotices(storeAfter, removed) local byName = {} for _, entry in ipairs(removed or {}) do byName[entry.runName] = (byName[entry.runName] or 0) + 1 end local notices = {} for _, run in ipairs(storeAfter.runs) do if byName[run.name] ~= nil then table.insert(notices, {runName = run.name, removed = byName[run.name], left = #run.vehicleIds, critical = #run.vehicleIds == 0}) end end return notices end -- En traktor er kun i ét run. Returnerer ny store + id på det run den blev flyttet FRA (nil = ingen). function ADRuns.assignVehicle(store, runId, vehicleId) local previous = ADRuns.findRunByVehicle(store, vehicleId) if previous ~= nil and previous.id == runId then return copyStore(store), nil end local new = ADRuns.unassignVehicle(store, vehicleId) local run = ADRuns.getRun(new, runId) if run == nil then return new, nil end table.insert(run.vehicleIds, vehicleId) return new, previous ~= nil and previous.id or nil end -- Varer gemmes som mellemrumsadskilte fillType-NAVNE ("STRAW FORAGE") — navne er -- stabile på tværs af mods/kort, fillType-indeks er det ikke. function ADRuns.serializeIngredients(list) return table.concat(list or {}, " ") end function ADRuns.parseIngredients(text) local list = {} for name in string.gmatch(tostring(text or ""), "%S+") do table.insert(list, name) end return list end -- Flade poster (kun strenge/tal) til XML-laget, i store'ens rækkefølge. function ADRuns.toRecords(store) local records = {} for index, run in ipairs(store.runs) do records[index] = { id = run.id, name = run.name, target = run.targetWayPointId or 0, targets = table.concat(run.targetWayPointIds, " "), ingredientMode = run.ingredientMode, ingredients = ADRuns.serializeIngredients(run.ingredients), waitPool = run.waitPoolGroup, loops = run.loops, returnBelow = run.returnBelowPercent, vehicleIds = copyList(run.vehicleIds), kind = run.kind, sources = table.concat(run.sourceWayPointIds or {}, " "), sourceBuildings = table.concat(run.sourceBuildingIds or {}, " "), targetBuildings = table.concat(run.targetBuildingIds or {}, " "), storeBuildings = table.concat(run.storeBuildingIds or {}, " "), products = ADRuns.serializeProducts(run.products), sellAt = run.sellAtPercent, storeAbove = run.storeAbovePercent, sellAbove = run.sellAbovePercent or 0, collectRound = run.collectRound and 1 or 0, stopped = run.stopped and 1 or 0, wagons = ADRuns.serializeWagons(run.wagons), chain = run.chain or "", } end return records end -- Poster uden gyldigt id/navn eller med dublet-id springes over; nextId = højeste id + 1. function ADRuns.fromRecords(records) local store = ADRuns.newStore() for _, record in ipairs(records or {}) do local id = tonumber(record.id) local isValid = id ~= nil and id >= 1 and record.name ~= nil and record.name ~= "" if isValid and ADRuns.getRun(store, id) == nil then local target = tonumber(record.target) local targets = {} for token in string.gmatch(tostring(record.targets or ""), "%S+") do local wayPointId = tonumber(token) if wayPointId ~= nil and wayPointId > 0 then table.insert(targets, wayPointId) end end if #targets == 0 and target ~= nil and target > 0 then targets = {target} end local function parseIds(text) local ids = {} for token in string.gmatch(tostring(text or ""), "%S+") do table.insert(ids, token) end return ids end local sources = {} for token in string.gmatch(tostring(record.sources or ""), "%S+") do local wayPointId = tonumber(token) if wayPointId ~= nil and wayPointId > 0 then table.insert(sources, wayPointId) end end table.insert(store.runs, { id = id, name = record.name, kind = ADRuns.KINDS[record.kind] and record.kind or "supply", sourceWayPointIds = sources, sourceBuildingIds = parseIds(record.sourceBuildings), targetBuildingIds = parseIds(record.targetBuildings), storeBuildingIds = parseIds(record.storeBuildings), products = ADRuns.parseProducts(record.products), sellAtPercent = tonumber(record.sellAt) == 0 and 0 or clampRange(record.sellAt, ADRuns.DEFAULT_SELL_AT_PERCENT, ADRuns.MIN_SELL_AT_PERCENT, ADRuns.MAX_SELL_AT_PERCENT), storeAbovePercent = clampRange(record.storeAbove, ADRuns.DEFAULT_STORE_ABOVE_PERCENT, ADRuns.MIN_STORE_ABOVE_PERCENT, ADRuns.MAX_STORE_ABOVE_PERCENT), sellAbovePercent = clampRange(record.sellAbove, ADRuns.DEFAULT_SELL_ABOVE_PERCENT, ADRuns.MIN_SELL_ABOVE_PERCENT, ADRuns.MAX_SELL_ABOVE_PERCENT), collectRound = tonumber(record.collectRound) == 1, targetWayPointId = targets[1], targetWayPointIds = targets, ingredientMode = ADRuns.INGREDIENT_MODES[record.ingredientMode] and record.ingredientMode or "auto", ingredients = ADRuns.parseIngredients(record.ingredients), waitPoolGroup = record.waitPool or "", loops = clampLoops(record.loops), returnBelowPercent = clampPercent(record.returnBelow), vehicleIds = copyList(record.vehicleIds), stopped = tonumber(record.stopped) == 1, wagons = ADRuns.parseWagons(record.wagons), chain = record.chain or "", }) store.nextId = math.max(store.nextId, id + 1) end end return store end -- ---- køretøjsnavne ---- -- Navn pr. køretøj efter det forløb det kører: "Milk Collect 1", "Milk Collect 2" … i medlemsrækkefølge; -- er køretøjet alene i forløbet, bare forløbets navn. {[vehicleId] = navn}. function ADRuns.vehicleNames(store) local names = {} for _, run in ipairs(store ~= nil and store.runs or {}) do local count = #(run.vehicleIds or {}) for index, vehicleId in ipairs(run.vehicleIds or {}) do names[vehicleId] = count > 1 and string.format("%s %d", tostring(run.name), index) or tostring(run.name) end end return names end -- true når navnet er et forløbsnavn vi selv har sat ("Navn" eller "Navn 3"). function ADRuns.isRunVehicleName(store, name) if name == nil then return false end for _, run in ipairs(store ~= nil and store.runs or {}) do local runName = tostring(run.name) if name == runName then return true end local prefix, number = string.match(name, "^(.-) (%d+)$") if prefix == runName and number ~= nil then return true end end return false end -- Navnet i log og menu: AutoDrives førernavn (sat efter forløbet), ellers køretøjets modelnavn. function ADRuns.vehicleLabel(vehicle) local stateModule = vehicle ~= nil and vehicle.ad ~= nil and vehicle.ad.stateModule or nil if stateModule ~= nil and stateModule.getName ~= nil then local ok, name = pcall(stateModule.getName, stateModule) if ok and name ~= nil and name ~= "" then return tostring(name) end end if vehicle ~= nil and vehicle.getName ~= nil then return tostring(vehicle:getName()) end return "?" end