Files
fs25-adsmartpickup/docs/superpowers/plans/2026-09-23-udkoersel.md
T

43 KiB
Raw Blame History

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/<fil>.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:

-- 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:

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).

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):

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):

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:
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):

-- 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:
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):

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):

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):

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:

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.