# Udkørsel af produkter — implementeringsplan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Runs med retning "Udkørsel": traktorer henter fabrikkers output og lagervarer (bulk og paller, også fra objektlagre) og kører dem til silo eller salgssted efter politik pr. vare, pristærskel og lagertærskel. **Architecture:** Ren planlægger (`adOutboundPlanner`) + rene prisregler (`adPrices`) uden spil-API; spiladaptere i `adSources`/`adPrices`; AutoDrive-integration i `adOutbound` via to hooks (næste pålæsning + læsseopgaven), samme mønster som `adUnloadWait`. Wait-puljen udskilles i `adWaitPool` og deles. GUI får tre faner (Oversigt · Forsyning · Udkørsel) med fælles byggeklodser. **Tech Stack:** FS25 Lua (GIANTS-sandbox), AutoDrive 3.x via `FS25_AutoDrive`-globalen, Universal Autoload via AD's AL-lag, luajit-tests uden spil. **Spec:** `docs/superpowers/specs/2026-09-23-udkoersel-design.md` ## Global Constraints - Alle spil-API-kald i hooks i `pcall`; fejl → hold ved Wait + WARN én gang, aldrig AutoDrive-stop. - Modellen (`adRuns`) muterer aldrig: ny store ved hver ændring. - Log-præfiks `ADSmartPickup: ` i alle moduler; alle run-ændringer logges. - XML-version 2; version 1-filer læses som `kind="supply"`. - Standarder: `sellAtPercent` 90 (50–100), `storeAbovePercent` 80 (50–95), `collectRound` false. - Politikker: `store` · `sell` · `sellElseStore` (+ "off" = varen udelades af `products`). - Kildetyper: fabrik (produktionspunkt), silo (`spec_silo`), objektlager (`spec_objectStorage`), genkendt inden for 40 m af markøren. - Tests køres fra repo-roden: `luajit tests/.lua`; alle eksisterende tests skal blive grønne. - Version bump til 1.12.0.0 i `modDesc.xml`; byg `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .`; deploy til `~/FS25-data/mods/`. ## Review Focus 1. Traktor med UAL-vogn står ved et objektlager hvis spawn sidder fast (`objectSpawn.isActive` for evigt): forventet: 30 s uden fremdrift → læsningen afsluttes med det der er, ingen palle efterlades ud over den ene der evt. er spawnet. (Task 6 test O5) 2. Bedst betalende salgssted er uopnåeligt, næstbedste kan nås: forventet: næstbedste vælges uden advarsel; kun når intet kan nås, vises rød advarsel én gang. (Task 4 test P6 + Task 6 test O7) 3. Vare hvis salgsstation ikke findes på kortet, politik `sell`: forventet: varen står som "intet salgssted" i UI og planlæggeren springer den over uden fejl. (Task 4 test P7) 4. Version 1-runs-fil (uden `kind`) indlæses: forventet: alle runs bliver Forsyning, intet går tabt, næste gem skriver version 2. (Task 1 test R-v2) 5. Bulk-vogn med opsamlingsrunde og to kilder hvor den første giver fuldt læs: forventet: der køres direkte til målet, kilde 2 besøges ikke. (Task 6 test O6) --- ### Task 1: Model + lagring v2 **Files:** - Modify: `FS25_ADSmartPickup/adRuns.lua` - Modify: `FS25_ADSmartPickup/adRunsStorage.lua` - Test: `tests/test_adRuns.lua` (tilføj sektion R-v2) **Interfaces:** - Produces: `ADRuns.KINDS = {supply=true, outbound=true}`, `ADRuns.POLICIES = {store=true, sell=true, sellElseStore=true}`, `ADRuns.DEFAULT_SELL_AT_PERCENT=90`, `ADRuns.DEFAULT_STORE_ABOVE_PERCENT=80`, `ADRuns.createRun(store, name, kind)`, run-felter `kind, sourceWayPointIds, products={{fillType="SUGAR", policy="sell"}}, sellAtPercent, storeAbovePercent, collectRound`, `ADRuns.toggleSource(store, runId, wayPointId)`, `ADRuns.setProductPolicy(store, runId, fillTypeName, policy)` (policy "off" fjerner), `ADRuns.getProductPolicy(run, fillTypeName)` → policy eller "off", `ADRuns.serializeProducts/parseProducts`, `ADRuns.XML_VERSION = 2`. - [ ] **Step 1: Skriv fejlende tests** — tilføj nederst i `tests/test_adRuns.lua` før den afsluttende opsummering: ```lua -- R-v2: udkørsels-run — kind, kilder, varer med politik, tærskler, runde; XML v2 + v1-migration local o1, out = ADRuns.createRun(ADRuns.newStore(), "Sugar out", "outbound") check("Rv2 kind", out.kind, "outbound") check("Rv2 supply som standard", ADRuns.createRun(ADRuns.newStore(), "x").kind == nil and "nil" or select(2, ADRuns.createRun(ADRuns.newStore(), "x")).kind, "supply") check("Rv2 sellAt 90", out.sellAtPercent, 90) check("Rv2 storeAbove 80", out.storeAbovePercent, 80) check("Rv2 runde fra", out.collectRound, false) local o2 = ADRuns.toggleSource(o1, out.id, 12694) o2 = ADRuns.toggleSource(o2, out.id, 12386) check("Rv2 to kilder", table.concat(ADRuns.getRun(o2, out.id).sourceWayPointIds, ","), "12694,12386") check("Rv2 toggle fjerner", #ADRuns.getRun(ADRuns.toggleSource(o2, out.id, 12694), out.id).sourceWayPointIds, 1) local o3 = ADRuns.setProductPolicy(o2, out.id, "SUGAR", "sell") o3 = ADRuns.setProductPolicy(o3, out.id, "FORAGE", "store") check("Rv2 politik SUGAR", ADRuns.getProductPolicy(ADRuns.getRun(o3, out.id), "SUGAR"), "sell") check("Rv2 politik ukendt = off", ADRuns.getProductPolicy(ADRuns.getRun(o3, out.id), "STRAW"), "off") check("Rv2 ugyldig politik ignoreres", ADRuns.getProductPolicy(ADRuns.getRun(ADRuns.setProductPolicy(o3, out.id, "SUGAR", "bogus"), out.id), "SUGAR"), "sell") check("Rv2 off fjerner", ADRuns.getProductPolicy(ADRuns.getRun(ADRuns.setProductPolicy(o3, out.id, "SUGAR", "off"), out.id), "SUGAR"), "off") check("Rv2 gammel store urørt", #ADRuns.getRun(o2, out.id).products, 0) local o4 = ADRuns.updateRun(o3, out.id, {sellAtPercent = 120, storeAbovePercent = 10, collectRound = true}) check("Rv2 sellAt klippet 100", ADRuns.getRun(o4, out.id).sellAtPercent, 100) check("Rv2 storeAbove klippet 50", ADRuns.getRun(o4, out.id).storeAbovePercent, 50) check("Rv2 runde til", ADRuns.getRun(o4, out.id).collectRound, true) check("Rv2 serialiser", ADRuns.serializeProducts(ADRuns.getRun(o3, out.id).products), "SUGAR:sell FORAGE:store") local parsed = ADRuns.parseProducts("SUGAR:sell FORAGE:store BAD BAD:bogus") check("Rv2 parse antal", #parsed, 2) check("Rv2 parse politik", parsed[2].policy, "store") local rt = ADRuns.fromRecords(ADRuns.toRecords(o4)) local rtRun = ADRuns.getRun(rt, out.id) check("Rv2 roundtrip kind", rtRun.kind, "outbound") check("Rv2 roundtrip kilder", table.concat(rtRun.sourceWayPointIds, ","), "12694,12386") check("Rv2 roundtrip products", ADRuns.serializeProducts(rtRun.products), "SUGAR:sell FORAGE:store") check("Rv2 roundtrip sellAt", rtRun.sellAtPercent, 100) check("Rv2 roundtrip runde", rtRun.collectRound, true) -- v1-post uden kind/sources/products -> supply med standarder local v1 = ADRuns.fromRecords({{id = 7, name = "Old", target = 94, targets = "94", ingredientMode = "auto", ingredients = "", waitPool = "", loops = 0, returnBelow = 50, vehicleIds = {}}}) check("Rv2 v1 -> supply", ADRuns.getRun(v1, 7).kind, "supply") check("Rv2 v1 tomme kilder", #ADRuns.getRun(v1, 7).sourceWayPointIds, 0) check("Rv2 v1 sellAt standard", ADRuns.getRun(v1, 7).sellAtPercent, 90) ``` - [ ] **Step 2: Kør testen** — `luajit tests/test_adRuns.lua` → FAIL (`toggleSource` nil). - [ ] **Step 3: Implementér i `adRuns.lua`**: ```lua ADRuns.XML_VERSION = 2 ADRuns.KINDS = {supply = true, outbound = true} 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 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 ``` `copyRun` får `kind, sourceWayPointIds = copyList(...), products = copyProducts(...), sellAtPercent, storeAbovePercent, collectRound`. `createRun(store, name, kind)` sætter `kind = ADRuns.KINDS[kind] and kind or "supply"`, `sourceWayPointIds = {}`, `products = {}`, `sellAtPercent = 90`, `storeAbovePercent = 80`, `collectRound = false`. `updateRun` håndterer `changes.kind` (kun gyldige), `sourceWayPointIds` (copyList), `products` (copyProducts), `sellAtPercent`/`storeAbovePercent` (clampRange), `collectRound` (`== true`). ```lua 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 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 ``` `toRecords` tilføjer `kind, sources = table.concat(run.sourceWayPointIds, " "), products = serializeProducts, sellAt, storeAbove, collectRound = run.collectRound and 1 or 0`. `fromRecords` læser dem med standarder (`kind` ugyldig/nil → "supply"; `collectRound = tonumber(record.collectRound) == 1`). `adRunsStorage.lua`: `writeRecords` skriver `#kind` (string), `#sources`, `#products`, `#sellAt` (int), `#storeAbove` (int), `#collectRound` (int); `readRecords` læser dem (`getString`/`getInt`; manglende attribut → nil → standard i fromRecords). - [ ] **Step 4: Kør** `luajit tests/test_adRuns.lua && luajit tests/test_adRunsController.lua` → alle OK. - [ ] **Step 5: Commit** `feat: run-model v2 — retning, kilder, varepolitik, tærskler, runde (XML v2 + v1-migration)` --- ### Task 2: adWaitPool udskilt fra adUnloadWait **Files:** - Create: `FS25_ADSmartPickup/adWaitPool.lua` - Modify: `FS25_ADSmartPickup/adUnloadWait.lua`, `adLoadSwap.lua` (excludedPlaceables), `modDesc.xml` (sourceFile før adUnloadWait) - Test: `tests/test_adWaitPool.lua` (ny), eksisterende `tests/test_adSmartPickup.lua` skal stadig køre **Interfaces (Produces):** ```lua ADWaitPool.getCandidates(adEnv, poolGroup) -- {{marker, x, z}} ADWaitPool.claim(markerId, vehicle) / ADWaitPool.releaseVehicle(vehicle) / ADWaitPool.getOwner(markerId) ADWaitPool.pickFree(adEnv, vehicle, poolGroup, isReachable) -- candidate eller nil (nærmeste ledige fra vognens position) ADWaitPool.hasRouteTo(adEnv, vehicle, wayPointId) / ADWaitPool.hasRouteBothWays(adEnv, vehicle, toId, homeId) ADWaitPool.redirect(task, destinationID) -- stopper AD's trigger-aktivitet, sætter task.destinationID/state/setPathTo ADWaitPool.getClaims() -- {{marker, vehicle}} sorteret (til GUI) ADWaitPool.STATE_DRIVING -- sættes af adUnloadWait.install (task.STATE_DRIVING or 2) ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, rest, homeWayPointId, isReachable, excludedPlaceables) -- 7. param: {[placeable]=true} udelukkes ``` - [ ] **Step 1: Test** `tests/test_adWaitPool.lua`: mock `FS25_AutoDrive.ADGraphManager` (markers i mapperne "Cow Wait" og "silo", waypoints, `getPathTo` der returnerer `{ {id=target} }` for nåelige), `ADSupplyPlanner` via `dofile`. Tjek: (W1) `getCandidates(adEnv, "Cow Wait")` giver kun Wait-markører i den mappe, tom pulje = alle Wait-mapper; (W2) `pickFree` returnerer nærmeste ledige, springer reserverede over, returnerer eget reserverede; (W3) `claim/releaseVehicle/getOwner`; (W4) `pickFree` med `isReachable` der afviser nærmeste → næste; (W5) `getClaims` sorteret på mappe. - [ ] **Step 2: Kør** → FAIL (fil mangler). - [ ] **Step 3: Implementér** `adWaitPool.lua` ved at flytte `getWaitCandidates`, `claims`, `pathReaches`, `hasRouteTo`, `hasRouteBothWays`, `redirect`, `getClaims`-sorteringen fra `adUnloadWait.lua` (samme kode, offentlige navne som ovenfor; `claims` er `setmetatable({}, {__mode="v"})`; `getClaims` itererer `claims` direkte: `{marker = ADGraphManager:getMapMarkerByWayPointId(id), vehicle}`). `adUnloadWait.lua` kalder dem (`claims[...]` → `ADWaitPool.claim/getOwner/releaseVehicle`; `pickNearestFreeWaitPoint(getWaitCandidates(...), claims, ...)` → `ADSupplyPlanner.pickNearestFreeWaitPoint(ADWaitPool.getCandidates(adEnv, pool), ADWaitPool.claims, x, z, vehicle, isReachable)`); `ADUnloadWait.getClaims/hasRouteTo/hasRouteBothWays` bliver delegater. `ADLoadSwap.findDumpMarker`: `if excludedPlaceables ~= nil and excludedPlaceables[station.owningPlaceable] then skip`. - [ ] **Step 4: Kør** alle tests: `for t in tests/test_*.lua; do luajit $t | tail -1; done` → grønne. - [ ] **Step 5: Commit** `refactor: Wait-puljen udskilt i adWaitPool (reservationer, rutetjek, redirect) til deling med udkørsel` --- ### Task 3: adPrices — toppris og pris-% **Files:** - Create: `FS25_ADSmartPickup/adPrices.lua` - Test: `tests/test_adPrices.lua` **Interfaces (Produces):** ```lua ADPrices.maxFactor(factors) -- højeste værdi i {[period]=factor}; 1 uden faktorer ADPrices.topPrice(baseNow, factorNow, maxFactor) -- baseNow * maxFactor / factorNow; baseNow når factorNow <= 0 ADPrices.percentOfTop(price, top) -- afrundet heltal; 0 når top <= 0 ADPrices.isSellingStation(station) -- SellingStation, isSellingPoint, ikke hideFromPricesMenu ADPrices.getQuote(station, fillType) -- {price, top, percent} eller nil (spil-API i pcall) ADPrices.bestQuote(quotes) -- højeste percent, dernæst price; nil ved tom ``` - [ ] **Step 1: Test** (P1) `maxFactor({EARLY_SPRING=0.9, LATE_AUTUMN=1.2})` = 1.2; (P2) `topPrice(500, 1.0, 1.25)` = 625; `topPrice(500, 0, 1.25)` = 500; (P3) `percentOfTop(470, 625)` = 75; `percentOfTop(700, 625)` = 112 (stor efterspørgsel > 100); `percentOfTop(1, 0)` = 0; (P4) `getQuote` med fake station `{fillTypePrices={[5]=500}, getEffectiveFillTypePrice=function(_,ft) return 470 end, isa=function() return true end, isSellingPoint=true}` og `g_fillTypeManager.getFillTypeByIndex` → `{economy={factors={A=1.0,B=1.25}}, pricePerLiter=400}`, `g_currentMission.environment.currentPeriod="A"` → percent 75; (P5) uden `fillTypePrices` bruges `pricePerLiter × factorNow × economyManager:getPriceMultiplier()` (1.0) → top 500, percent 94 (470/500); (P6) `bestQuote` vælger højeste percent. - [ ] **Step 2: Kør** → FAIL. - [ ] **Step 3: Implementér**: ```lua ADPrices = {} ADPrices.LOG_PREFIX = "ADSmartPickup: " function ADPrices.maxFactor(factors) local best = nil for _, factor in pairs(factors or {}) do if type(factor) == "number" and (best == nil or factor > best) then best = factor end end return best or 1 end function ADPrices.topPrice(baseNow, factorNow, maxFactor) if baseNow == nil or baseNow <= 0 then return 0 end if factorNow == nil or factorNow <= 0 or maxFactor == nil or maxFactor <= 0 then return baseNow end return baseNow * maxFactor / factorNow end function ADPrices.percentOfTop(price, top) if top == nil or top <= 0 or price == nil then return 0 end return math.floor(100 * price / top + 0.5) end function ADPrices.isSellingStation(station) return station ~= nil and station.isa ~= nil and SellingStation ~= nil and station:isa(SellingStation) and station.isSellingPoint == true and not station.hideFromPricesMenu end local function getCurrentPeriod() local environment = g_currentMission ~= nil and g_currentMission.environment or nil return environment ~= nil and environment.currentPeriod or nil end function ADPrices.getQuote(station, fillType) local ok, effective = pcall(station.getEffectiveFillTypePrice, station, fillType) if not ok or type(effective) ~= "number" then return nil end local fillTypeDesc = g_fillTypeManager ~= nil and g_fillTypeManager.getFillTypeByIndex ~= nil and g_fillTypeManager:getFillTypeByIndex(fillType) or nil local factors = fillTypeDesc ~= nil and fillTypeDesc.economy ~= nil and fillTypeDesc.economy.factors or nil local period = getCurrentPeriod() local factorNow = factors ~= nil and period ~= nil and tonumber(factors[period]) or 1 local baseNow = station.fillTypePrices ~= nil and tonumber(station.fillTypePrices[fillType]) or nil if baseNow == nil or baseNow <= 0 then local multiplier = 1 local economy = g_currentMission ~= nil and g_currentMission.economyManager or nil if economy ~= nil and economy.getPriceMultiplier ~= nil then local okMult, value = pcall(economy.getPriceMultiplier, economy) if okMult and type(value) == "number" then multiplier = value end end baseNow = (fillTypeDesc ~= nil and tonumber(fillTypeDesc.pricePerLiter) or 0) * factorNow * multiplier end local top = ADPrices.topPrice(baseNow, factorNow, ADPrices.maxFactor(factors)) return {price = effective, top = top, percent = ADPrices.percentOfTop(effective, top)} end function ADPrices.bestQuote(quotes) local best = nil for _, quote in ipairs(quotes or {}) do if best == nil or quote.percent > best.percent or (quote.percent == best.percent and quote.price > best.price) then best = quote end end return best end ``` - [ ] **Step 4: Kør** → OK. **Step 5: Commit** `feat: adPrices — toppris fra årets prisforløb og pris-% pr. salgssted` --- ### Task 4: adOutboundPlanner (ren) **Files:** - Create: `FS25_ADSmartPickup/adOutboundPlanner.lua` - Test: `tests/test_adOutboundPlanner.lua` **Interfaces (Produces):** ```lua -- input: -- { capacity = liter, hasAutoload = bool, sellAtPercent, storeAbovePercent, collectRound, -- products = { { fillType = idx, name = "SUGAR", policy = "sell"|"store"|"sellElseStore", isPallet = bool, -- sources = { {wayPointId, available, level, capacity} ... }, -- kun kilder med available > 0 -- quote = {percent, price, wayPointId, stationName} | nil, -- bedste NÅELIGE salgssted -- store = {wayPointId, name, free} | nil } ... } } -- output: trip | nil, reasons -- trip = { kind = "sell"|"store", fillTypes = {idx...}, names = {...}, sourceIds = {wayPointId...}, targetWayPointId, targetName, percent|nil } -- reasons = { "SUGAR 78 % af top (kræver 90), lager 41 %", ... } ADOutboundPlanner.plan(input) ADOutboundPlanner.evaluate(product, input) -- {available, fullLoad, storageHigh, storagePercent, priceOk, percent} ADOutboundPlanner.MIN_AVAILABLE_LITERS = 1 ADOutboundPlanner.ROUND_MIN_FREE_FRACTION = 0.1 ``` - [ ] **Step 1: Tests** (`local P = ADOutboundPlanner` efter `dofile`): - P1 salg ved pris over tærskel, uanset mængde: SUGAR sell, sources {{1, 5000, ...}}, quote 92, capacity 20000 → trip.kind "sell", targetWayPointId = quote.wayPointId, sourceIds {1}. - P2 pris under tærskel, lager lavt → nil, reasons[1] = "SUGAR 78 % af top (kræver 90), lager 41 %". - P3 sellElseStore, pris lav, lager 85 % → "store" til store.wayPointId. - P4 store med fuldt læs (available ≥ capacity) og lager 20 % → store. - P5 rangering: to sell-kandidater 95 % og 91 % → første vælges (højeste %). - P6 quote nil (intet nåeligt salgssted), policy sell, lager højt → nil, reason "SUGAR: intet nåeligt salgssted". - P7 store nil ved store-behov → nil, reason "FORAGE: ingen silo med plads". - P8 runde bulk (collectRound, hasAutoload=false): SUGAR i kilde A (3000) og B (9000), capacity 10000 → sourceIds {B, A} (mest først), ét fillType. - P9 runde UAL: STRAW_BASKET (sell 95, station S) + STRAW_CRATE (sell 93, station S) + STRAW_BROOM (sell 96, station T) → fillTypes {BROOM} kun (højest, T) — og med BROOM fjernet: {BASKET, CRATE} samme station S. - P10 uden runde: kun første fillType og kun én kilde (den med mest). - P11 policy off/ingen sources → nil, reasons tom for varer uden kilder ("SUGAR: intet på lager"). - [ ] **Step 2: Kør** → FAIL. **Step 3: Implementér**: ```lua ADOutboundPlanner = {} ADOutboundPlanner.MIN_AVAILABLE_LITERS = 1 ADOutboundPlanner.ROUND_MIN_FREE_FRACTION = 0.1 local function sumAvailable(product) local total, high, percent = 0, false, 0 for _, source in ipairs(product.sources or {}) do total = total + (source.available or 0) if source.capacity ~= nil and source.capacity > 0 then local p = 100 * (source.level or 0) / source.capacity percent = math.max(percent, p) end end return total, percent end function ADOutboundPlanner.evaluate(product, input) local available, storagePercent = sumAvailable(product) local percent = product.quote ~= nil and product.quote.percent or nil return { available = available, fullLoad = input.capacity ~= nil and input.capacity > 0 and available >= input.capacity, storagePercent = storagePercent, storageHigh = storagePercent >= (input.storeAbovePercent or 80), percent = percent, priceOk = percent ~= nil and percent >= (input.sellAtPercent or 90), } end local function wantsSell(policy) return policy == "sell" or policy == "sellElseStore" end local function wantsStore(policy) return policy == "store" or policy == "sellElseStore" end local function reasonFor(product, e, input) if e.available < ADOutboundPlanner.MIN_AVAILABLE_LITERS then return product.name .. ": intet på lager" end if wantsSell(product.policy) and product.quote == nil and not (wantsStore(product.policy) and product.store ~= nil) then return product.name .. ": intet nåeligt salgssted" end if wantsStore(product.policy) and product.store == nil and (e.fullLoad or e.storageHigh) and not e.priceOk then return product.name .. ": ingen silo med plads" end local parts = {} if wantsSell(product.policy) and e.percent ~= nil then table.insert(parts, string.format("%d %% af top (kræver %d)", e.percent, input.sellAtPercent or 90)) end table.insert(parts, string.format("lager %d %%", math.floor(e.storagePercent + 0.5))) return product.name .. " " .. table.concat(parts, ", ") end -- kandidater: {product, e, kind, targetWayPointId, targetName, score} local function collectCandidates(input) local sell, store, reasons = {}, {}, {} for _, product in ipairs(input.products or {}) do local e = ADOutboundPlanner.evaluate(product, input) local added = false if e.available >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then if wantsSell(product.policy) and e.priceOk and product.quote ~= nil then table.insert(sell, {product = product, e = e, kind = "sell", targetWayPointId = product.quote.wayPointId, targetName = product.quote.stationName, percent = e.percent}) added = true elseif wantsStore(product.policy) and (e.fullLoad or e.storageHigh) and product.store ~= nil then table.insert(store, {product = product, e = e, kind = "store", targetWayPointId = product.store.wayPointId, targetName = product.store.name}) added = true end end if not added then table.insert(reasons, reasonFor(product, e, input)) end end table.sort(sell, function(a, b) if a.e.percent ~= b.e.percent then return a.e.percent > b.e.percent end if a.e.available ~= b.e.available then return a.e.available > b.e.available end return a.product.name < b.product.name end) table.sort(store, function(a, b) if a.e.storagePercent ~= b.e.storagePercent then return a.e.storagePercent > b.e.storagePercent end if a.e.available ~= b.e.available then return a.e.available > b.e.available end return a.product.name < b.product.name end) return sell, store, reasons end local function sortedSources(product) local sources = {} for _, source in ipairs(product.sources or {}) do if (source.available or 0) >= ADOutboundPlanner.MIN_AVAILABLE_LITERS then table.insert(sources, source) end end table.sort(sources, function(a, b) return a.available > b.available end) return sources end local function buildTrip(primary, candidates, input) local trip = {kind = primary.kind, fillTypes = {primary.product.fillType}, names = {primary.product.name}, sourceIds = {}, targetWayPointId = primary.targetWayPointId, targetName = primary.targetName, percent = primary.percent} local members = {primary} if input.collectRound and input.hasAutoload and primary.product.isPallet then for _, candidate in ipairs(candidates) do if candidate ~= primary and candidate.kind == primary.kind and candidate.targetWayPointId == primary.targetWayPointId and candidate.product.isPallet then table.insert(members, candidate) table.insert(trip.fillTypes, candidate.product.fillType) table.insert(trip.names, candidate.product.name) end end end local seen = {} for _, member in ipairs(members) do local sources = sortedSources(member.product) local limit = input.collectRound and #sources or math.min(1, #sources) for index = 1, limit do local id = sources[index].wayPointId if not seen[id] then seen[id] = true; table.insert(trip.sourceIds, id) end end end return trip end function ADOutboundPlanner.plan(input) local sell, store, reasons = collectCandidates(input) if sell[1] ~= nil then return buildTrip(sell[1], sell, input), reasons end if store[1] ~= nil then return buildTrip(store[1], store, input), reasons end return nil, reasons end ``` - [ ] **Step 4: Kør** → OK. **Step 5: Commit** `feat: adOutboundPlanner — turvurdering, prisregel, politikker, opsamlingsrunde (ren)` --- ### Task 5: adSources — kilde-genkendelse og lagerstatus **Files:** - Create: `FS25_ADSmartPickup/adSources.lua` - Test: `tests/test_adSources.lua` **Interfaces (Produces):** ```lua ADSources.MAX_DISTANCE = 40; ADSources.PALLET_RADIUS = 25 ADSources.identify(adEnv, wayPointId, farmId) -- {kind="factory"|"silo"|"objectStorage", placeable, station|nil, wayPointId, name} | nil ADSources.getProducts(adEnv, source, farmId) -- { {fillType, name, available, level, capacity, isPallet} } (kun varer med kapacitet/indhold) ADSources.isPalletFillType(fillType) -- fillTypeDesc.palletFilename ~= nil ADSources.getSellMarkers(adEnv, farmId) -- { {marker, station} } salgssteder (ADPrices.isSellingStation) ADSources.getObjectInfoIndex(source, fillType) -- objectInfo-indeks + antal for varen i et objektlager, eller nil ADSources.spawnOne(source, objectInfoIndex) -- pcall placeable:removeAbstractObjectsFromStorage(index, 1, nil) -> ok ADSources.countStored(source, fillType) -- antal objekter af varen i lageret ADSources.getSourcePlaceables(sources) -- {[placeable]=true} til udelukkelse ``` Genkendelse: læssestation ved markøren (`ADSmartPickup.getLoadStationAtWayPointId`) med `owningPlaceable.spec_productionPoint` → factory; med `spec_silo` → silo; ellers nærmeste placeable med `spec_objectStorage` inden for 40 m af waypointet (`g_currentMission.placeableSystem.placeables`, `getWorldTranslation(placeable.rootNode)`, `canFarmAccess`) → objectStorage. Fabrik-produkter: `for production in productionPoint.productions: for output in production.outputs: type` (unik); `level = productionPoint.storage:getFillLevel(ft)`, `capacity = storage:getCapacity(ft)` (fallback `storage.capacities[ft]`); paller i nærheden: `g_currentMission.vehicleSystem.vehicles` med `spec_pallet` eller `isPallet`, ejer = farm, fillUnit-varen = ft, afstand ≤ 25 m fra `productionPoint.palletSpawner.spawnPlaces[1].startNode` (fallback placeable.rootNode); `available = level + Σ palle-fyldning`. Silo: `spec_silo.storages[*]` fillLevels > 0 → `level = getOwnStorageLevel`, `capacity = storage:getCapacity(ft)`. Objektlager: `spec_objectStorage.objectInfos[i].objects[1]` med `palletAttributes.fillType/fillLevel` eller `baleAttributes.fillType/fillLevel`; `numObjects` → available = numObjects × fillLevel; level = `spec.numStoredObjects`, capacity = `spec.capacity`; isPallet = true. - [ ] **Step 1: Tests** med fakes: (S1) markør ved fabrik → factory med SUGAR output level 150000, capacity 180000, ingen paller → available 150000; (S2) markør ved silo → silo med FORAGE 74500; (S3) markør uden station, objektlager 20 m væk → objectStorage, produkt STRAW_BASKET available 8×1000, isPallet true, `getObjectInfoIndex` = 1 og 8; (S4) paller ved spawner: 2 paller à 1000 STRAW_BASKET inden for 25 m + 1 uden for → available = storage + 2000; (S5) `getSellMarkers` giver kun markører hvis unload-station er SellingStation med isSellingPoint; (S6) `spawnOne` kalder `removeAbstractObjectsFromStorage(1, 1, nil)` (fake tæller); (S7) markør 60 m fra objektlager → nil. - [ ] **Step 2–4:** implementér som beskrevet, kør, grønne. **Step 5: Commit** `feat: adSources — fabrik, silo og objektlager som kilder; paller ved spawner; salgssteder` --- ### Task 6: adOutbound — hooks, hold, objektlager-udtag, runde, advarsler **Files:** - Create: `FS25_ADSmartPickup/adOutbound.lua` - Test: `tests/test_adOutbound.lua` **Interfaces (Produces):** ```lua ADOutbound.CHECK_INTERVAL_MS = 5000; ADOutbound.NO_PROGRESS_MS = 15000; ADOutbound.SPAWN_STALL_MS = 30000 ADOutbound.isOutboundVehicle(vehicle) -- run for vognen har kind "outbound" ADOutbound.choosePickup(adEnv, vehicle) -- kaldes fra getNextPickup-wrap; returnerer choice {markerIndex, markerName, group, station|nil, fillTypes, fillType} eller nil ADOutbound.buildPlanInput(adEnv, run, vehicle) -- samler kilder/priser/lager til planlæggeren (spil-API) ADOutbound.beforeLoadUpdate(adEnv, task, dt) -- true = håndteret (spring original update over) ADOutbound.beforeLoadFinished(adEnv, task) -- true = fortsæt runde (spring original finished over) ADOutbound.releaseInactive() ADOutbound.getHoldFor(vehicle) -- {markerName, reasons} | nil ADOutbound.getRunStatus(run) -- {reasons, warning} (seneste) ADOutbound.install(adEnv) -- wrapper LoadAtDestinationTask.update/finished ``` Adfærd (fra spec afsnit 3–4): - `choosePickup`: `trips[vehicle]=nil`; input = buildPlanInput; `trip, reasons = ADOutboundPlanner.plan(input)`; status[run.id] = reasons. Tur → `stateModule.selectedFillTypes = trip.fillTypes; setFillType(trip.fillTypes[1]); setSecondMarkerByWayPointId(trip.targetWayPointId); raiseDirtyFlag`; `trips[vehicle] = {run, trip, queue = copy(trip.sourceIds), visited = {}, current = trip.sourceIds[1]}`; log `'MT635' udkørsel: 8×STRAW_BASKET fra Straw factory → Halm-salg (95 % af top)`; returnér choice for første kilde (station = kildens læssestation hvis bulk, ellers nil). Ingen tur → `ADWaitPool.pickFree(adEnv, vehicle, run.waitPoolGroup, isReachable=hasRouteTo)`; fundet → claim, `holds[vehicle] = {markerId, reasons}`, log én gang pr. ændret begrundelse, returnér choice for Wait-markøren (station nil); intet Wait-punkt → hold ved første kilde (warn én gang) og returnér den. - `buildPlanInput`: for hver kilde i `run.sourceWayPointIds`: `ADSources.identify` (nil → spring over + warn én gang "kilde markør X ikke genkendt"); products = union over kilder af `ADSources.getProducts`, filtreret til `ADRuns.getProductPolicy ~= "off"` og til det vognen kan bære (`bulk: AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically`, pallet: hasAutoload = `AutoDrive:hasAL(trailer)` for en af `getAllUnits`); pr. produkt med sell-politik: quotes over `ADSources.getSellMarkers` hvor `station.acceptedFillTypes[ft]` (eller `getIsFillTypeSupported`) og `ADWaitPool.hasRouteBothWays(adEnv, vehicle, marker.id, sourceId)` → `ADPrices.getQuote`; bedste = `ADPrices.bestQuote`; ingen nåelig men mindst én unåelig → warnOnce(vehicle, "sell:"..ft, "'%s' kan ikke nå salgssted '%s' for %s – tjek vejnettet"); pr. produkt med store-politik: `ADLoadSwap.findDumpMarker(adEnv, vehicle, ft, math.min(available, capacity), nil, isReachable, ADSources.getSourcePlaceables(sources))` → store; nil → warnOnce(vehicle, "store:"..ft, "'%s' finder ingen silo med plads til %s (%s)"). - `warnOnce(vehicle, key, text)`: `warned[vehicle][key] ~= text` → Logging.warning + pcall `adEnv.AutoDriveMessageEvent.sendMessageOrNotification(vehicle, adEnv.ADMessagesManager.messageTypes.ERROR, "ADSmartPickup: " .. text, 8000)`; status[run.id].warning = text. Ryddes når nøglens tilstand ændrer sig (ny tekst) eller turen lykkes. - `beforeLoadUpdate`: hold → hvis `task.destinationID == hold.markerId` og `drivePathModule:isTargetReached()`: `specialDrivingModule:stopVehicle(); specialDrivingModule:update(dt)`; timer 5 s → `choosePickup` igen; tur → `ADWaitPool.releaseVehicle`, `holds[vehicle]=nil`, `ADWaitPool.redirect(task, trip.sourceIds[1])`, `task.activatedUALLoading=false`; returnér true. Ikke fremme → false. Tur aktiv og `isTargetReached()`: kilde = `state.sources[task.destinationID]`; objektlager → `updateSpawn` (nedenfor); derefter fremdriftsvagt: `level = AutoDrive.getAllFillLevels(task.trailers)`; stiger → `noProgressMs=0`; ellers `+dt`; `> NO_PROGRESS_MS` og `level > 0` og ingen spawn i gang → `tryNextSourceOrFinish(task)`; returnér false (original kører altid, den aktiverer UAL og stopper vognen). - `updateSpawn(state, task, source, dt)`: første gang: `index = ADSources.getObjectInfoIndex(source, currentFillType)`; hvis `task.activatedUALLoading` og vognen står (`vehicle.lastSpeedReal < 0.0005`): hvis ingen udestående spawn: `ualIsFull()` på en UAL-vogn → done; `countStored == 0` → skift til næste fillType i `trip.fillTypes` (nyt index) eller done; ellers `ADSources.spawnOne` → `pending = {stored = countStored, loaded = countLoaded(trailers), ms = 0}`; udestående: `countStored < pending.stored and countLoaded > pending.loaded` → pending=nil (næste); `pending.ms > SPAWN_STALL_MS` → done + warn "objektlager spawner ikke". `countLoaded` = antal nøgler i `spec_universalAutoload.loadedObjects` summeret over trailers. done → `task:finished()` (via original finished) og returnér. - `tryNextSourceOrFinish(task)`: næste id i `state.queue` som ikke er besøgt, og fri kapacitet ≥ 10 % (`fillFreeCapacity/capacity`) → `visited[current]=true; current=next; ADWaitPool.redirect(task, next); task.activatedUALLoading=false; activePickups opdateres via ADSmartPickup.setActivePickup(vehicle, station, fillTypes)` (ny lille funktion i adSmartPickup); ellers `task:finished()`. - `beforeLoadFinished`: kaldes fra wrap af `finished`; hvis der er tur, kilde ikke fuld (ikke `filledToUnload`) og en ubesøgt kilde med plads → redirect som ovenfor og returnér true (spring original over); ellers `trips[vehicle]=nil` og false. - `releaseInactive`: for inaktive køretøjer: trips/holds ryddes, `ADWaitPool.releaseVehicle`. - [ ] **Step 1: Tests** (fake AD-miljø som i `test_adRunsController.lua` + fakes for `ADSources`, `ADPrices`, `ADWaitPool`, `ADRunsController.getRunForVehicle`): (O1) tur → selectedFillTypes/2. markør sat, choice = kilde; (O2) ingen tur → Wait-markør claimet, hold registreret, choice = Wait; (O3) hold + 5 s + nu tur → redirect til kilde, hold frigivet; (O4) objektlager: spawn kaldes én ad gangen; stored falder + loaded stiger → næste spawn; `ualIsFull` → finished kaldt; (O5) spawn uden fremdrift 30 s → finished + WARN; (O6) runde bulk: kilde A fylder vognen (`filledToUnload`) → finished kaldt, ingen redirect; kilde A giver 30 % → redirect til B; (O7) intet nåeligt salgssted → `sendMessageOrNotification` kaldt én gang med ERROR, anden gang ikke; (O8) `releaseInactive` rydder hold og claim. - [ ] **Step 2–4:** implementér, kør, grønne. **Step 5: Commit** `feat: adOutbound — udkørsels-hooks: turvalg, hold ved Wait, objektlager-udtag, opsamlingsrunde, advarsler på skærm` --- ### Task 7: Controller — udkørsels-runs **Files:** - Modify: `FS25_ADSmartPickup/adRunsController.lua` - Test: `tests/test_adRunsController.lua` (sektion C-out) **Interfaces (Produces):** ```lua ADRunsController.createRun(name, kind) ADRunsController.getSourceMarkers(adEnv, farmId) -- markører der er kilder: {marker, kind} ADRunsController.getRunProducts(adEnv, run) -- { {fillType, name, title, policy, isPallet, available, percent|nil, stationName|nil} } sorteret på titel ADRunsController.validateVehicle(adEnv, run, vehicle) -- outbound: reasons noSource | noTrailer | noProducts | needsAutoload | cannotCarry | farFromNetwork ADRunsController.getOutboundStatus(adEnv, run) -- {products = getRunProducts, reasons, warning} (fra ADOutbound.getRunStatus) ``` `applyRunToVehicle`: `if run.kind == "outbound"`: mode P&D, `setFirstMarkerByWayPointId(run.sourceWayPointIds[1])`, `setSecondMarkerByWayPointId(run.sourceWayPointIds[1])`, `selectedFillTypes` = produkt-fillTypes vognen kan bære, `setFillType(first)`, loopCounter 0, useFolders true, rotateTargets ONLYPICKUP; ingen dump-logik. `getVehicleActivity`: `ADOutbound.getHoldFor(vehicle)` → `{kind="waiting", place=markerName, reason=reasons[1]}`. `updateRun` logger som før. `getRunTargets` for outbound returnerer `{}` (mål vælges pr. tur). - [ ] **Step 1: Tests** (C-out1) createRun kind; (C-out2) validate: ingen kilder → noSource; bulk-vogn og kun pallevarer → needsAutoload; ingen politik → noProducts; ok-tilfælde; (C-out3) startRun sætter 1./2. markør = kilde 1 og selectedFillTypes; (C-out4) getRunProducts slår politik op. - [ ] **Step 2–4:** implementér, kør alle tests. **Step 5: Commit** `feat: controller — udkørsels-runs: start, validering, produkter, status` --- ### Task 8: Wiring i adSmartPickup, adUnloadWait, modDesc **Files:** - Modify: `adSmartPickup.lua` (source() nye gui-filer, `installHook`: outbound-gren i `getNextPickup`-wrap, `ADOutbound.install(adEnv)`, `ADSmartPickup.setActivePickup`, `update`: `ADOutbound.releaseInactive`), `adUnloadWait.lua` (`beforeUpdate`: return hvis `ADOutbound.isOutboundVehicle`), `modDesc.xml` (sourceFiles: adPrices, adOutboundPlanner, adSources, adWaitPool, adOutbound før adUnloadWait/adSmartPickup; version 1.12.0.0; beskrivelse EN udvidet med udkørsel). `getNextPickup`-wrap, ny gren først: ```lua if ADOutbound ~= nil and ADOutbound.isOutboundVehicle(driver) then local ok, choice = pcall(ADOutbound.choosePickup, adEnv, driver) if not ok then Logging.warning(ADSmartPickup.LOG_PREFIX .. "fejl i udkørsel, bruger AutoDrives rotation: %s", tostring(choice)) activePickups[driver] = nil return originalGetNextPickup(self, driver, forcedSkip) end if choice == nil then activePickups[driver] = nil; return originalGetNextPickup(self, driver, forcedSkip) end activePickups[driver] = choice.station ~= nil and {station = choice.station, fillTypes = choice.fillTypes, markerName = choice.markerName, fillType = choice.fillType} or nil self.pickups[choice.group] = self.pickups[choice.group] or {} self.pickups[choice.group].lastTarget = choice.markerIndex self.pickups[choice.group].lastVehicle = driver return choice.markerIndex end ``` - [ ] **Step 1:** ret filer. **Step 2:** `luajit tests/test_adSmartPickup.lua` grøn; `for t in tests/test_*.lua; do luajit $t | tail -1; done`. **Step 3: Commit** `feat: udkørsel koblet på AutoDrive-hooks; Wait-logik springer udkørsels-traktorer over` --- ### Task 9: GUI — tre faner **Files:** - Modify: `gui/SmartPickupFrame.lua` (faner, fælles), `gui/SmartPickupFrame.xml` (nye statiske rækker + productPrefab + sourcePrefab), `l10n/l10n_da.xml`, `l10n/l10n_en.xml` - Create: `gui/SmartPickupSupplyTab.lua` (flyt: fillTargetRows, fillNeedsRows, refreshNeedsRows, fillIngredientRows, describeTargetNeeds, onClickTarget/IngredientMode/Ingredient/Loops/ReturnBelow, `SmartPickupFrame:rebuildSupplyTab(adEnv, run)`), `gui/SmartPickupOutboundTab.lua` (`rebuildOutboundTab(adEnv, run)`: Kilder-rækker (ingredientPrefab med kind="source"), Varer-rækker (productPrefab: MultiTextOption med 4 tilstande Fra/Lager/Salg/Salg, ellers lager + titel "Sukker — 150.0k · 78 % af top (Grain Elevator)"), Regler (sellAtOption 50..100 trin 5, storeAboveOption 50..95 trin 5, collectRoundOption), status-/advarselsrække; callbacks onClickSource/onClickPolicy/onClickSellAt/onClickStoreAbove/onClickCollectRound; `describeOutboundOverview(run)` til Oversigt). - `SmartPickupFrame`: `TAB_OVERVIEW, TAB_SUPPLY, TAB_OUTBOUND = 1, 2, 3`, `TAB_NAMES = {"spu_tab_overview","spu_tab_supply","spu_tab_outbound"}`, `tabKind = {nil, "supply", "outbound"}`; `fillRunOption` viser kun runs med fanens kind; `onNewRunName` opretter med fanens kind (Oversigt → supply); `setStaticRowsVisible(tab)` viser forsynings-rækker (ingredientMode, waitPool, loops, returnBelow) kun på fane 2, udkørsels-rækker (waitPool, sellAt, storeAbove, collectRound) kun på fane 3 (waitPool er fælles); blok-overskrifter via `addTextRow` ("— Mål og varer —" stil: brug tekstnøglerne `spu_section_*`). `refreshLive` opdaterer også produktrækkernes titler (pris-%) hvert sekund og udkørsels-status. - l10n (da/en): `spu_tab_supply` "Forsyning"/"Supply", `spu_tab_outbound` "Udkørsel"/"Outbound", `spu_sources` "Kilder (fabrik, silo eller objektlager)", `spu_products` "Varer", `spu_policy_off/store/sell/sellElseStore` "Fra/Lager/Salg/Salg, ellers lager", `spu_sellAt` "Sælg fra (% af årets toppris)", `spu_sellAt_tooltip`, `spu_storeAbove` "Kør til lager over", `spu_storeAbove_tooltip`, `spu_collectRound` "Saml op på én tur", `spu_collectRound_tooltip`, `spu_section_run/targets/rules/wait/vehicles/status`, `spu_reason_noSource` "ingen genkendt kilde", `spu_reason_noProducts` "ingen vare valgt", `spu_reason_needsAutoload` "pallevarer kræver Universal Autoload på vognen", `spu_product_row` "%s — %s · %s", `spu_price_pct` "%d %% af top (%s)", `spu_price_none` "intet salgssted", `spu_outbound_waiting` "venter: %s", `spu_activity_holding` "holder ved %s (%s)". - [ ] **Step 1:** XML + l10n; **Step 2:** split + ny fane; **Step 3:** `luajit` alle tests (GUI testes in-game); **Step 4: Commit** `feat(gui): faner Oversigt/Forsyning/Udkørsel; udkørsels-run med kilder, varepolitik, regler` --- ### Task 10: README, version, byg, deploy, in-game - [ ] README: nyt afsnit "Udkørsel (v1.12)" (kilder, politikker, prisregel, lagertærskel, runde, objektlager-krav om markørplacering, advarsler). modDesc 1.12.0.0. - [ ] `cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/` - [ ] Commit `chore: v1.12.0.0 — udkørsel af produkter` + push til Gitea (`git push gitea main` med token fra vault). - [ ] In-game-test (bruger): savegame 3 — se spec afsnit 4.