diff --git a/docs/superpowers/plans/2026-09-23-udkoersel.md b/docs/superpowers/plans/2026-09-23-udkoersel.md new file mode 100644 index 0000000..b9f1c1f --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-udkoersel.md @@ -0,0 +1,569 @@ +# 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.