Files
fs25-adsmartpickup/docs/superpowers/plans/2026-09-27-marksilo-flow.md
T

50 KiB
Raw Blame History

Marksilo-flow Implementation Plan

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: Et fast flow der hvert 60. s kigger alle marksiloer igennem og låner op til x ledige vogne til at køre varerne hjem til nærmeste valgte gårdlager.

Architecture: Ren planlægger (adFieldSiloPlanner.lua) + ren opsætningsmodel (adFieldSiloConfig.lua), begge testet med luajit. Et tyndt spil-lag (adFieldSilos.lua) samler input fra spillet, låner vogne via AutoDrive "Hent og aflever" og frigiver dem. Egen underfane gui/SmartPickupFieldSiloTab.lua. Markflådens getRigs markerer lånte vogne som optaget.

Tech Stack: Lua 5.1 (FS25 / LuaJIT), AutoDrive 3.0.1.2 API, Courseplay-specs via ADCpBridge, tests med luajit tests/test_*.lua fra repo-roden.

Spec: docs/superpowers/specs/2026-09-27-marksilo-flow-design.md

Global Constraints

  • Branch: feature/marksilo-flow; merge til main efter review; version 1.36.0.0 i modDesc.xml.
  • Log-præfiks ADSmartPickup: (via Logging.info/warning), tekster på dansk i log, l10n-nøgler spu_fs_* i både l10n_da.xml og l10n_en.xml.
  • Tal fra spec'en: planlægning hvert 60000 ms, maxVehicles 1–10 standard 2, minLiters ∈ {5000, 10000, 25000, 50000} standard 10000, vagt 120000 ms, pause efter vagt 600000 ms, marksilo-afstand = ADFields.FIELD_SILO_DISTANCE (150).
  • Kun vogne med rolle unloader fra ADFieldWork.getRigs må lånes; enabled and not busy and not controlled.
  • Gårdlagre er bygninger med kind == "silo"; en valgt gårdlager-bygning er aldrig marksilo.
  • Lån gemmes IKKE; opsætningen gemmes i ADSmartPickup_fieldsilos.xml sammen med runs.
  • Ingen mutation af input i de rene moduler (projektets stil: ny tabel ud).

Review Focus

  • Vogn med rest af en ANDEN vare på sig → må ikke lånes til en ny vare (planner-test "vogn med anden vare springes over").
  • Gårdlager fyldes op midt i runden → to vogne må ikke begge sendes til et lager med plads til kun én (planner reserverer plads pr. lager; test).
  • Marksilo med flere varer (fx hvede + raps) → hver vare er sin egen opgave, og lån tælles pr. (silo, vare) (test).
  • Spilleren sætter sig i en lånt vogn → lånet slettes straks, og vognen bruges ikke igen før den er ledig (loan-lifecycle-test i Task 3).
  • Flowet slås fra mens vogne er ude → AD stoppes på lånte vogne, og lånene slettes (Task 3-test).

File Structure

Fil Ansvar
Create FS25_ADSmartPickup/adFieldSiloPlanner.lua Ren planlægning: kvalificering, rækkefølge, vogn- og lagervalg
Create FS25_ADSmartPickup/adFieldSiloConfig.lua Ren opsætningsmodel + records til/fra XML
Create FS25_ADSmartPickup/adFieldSilos.lua Spil-lag: input, lån, AD-start, frigivelse, vagt, gem/indlæs
Create FS25_ADSmartPickup/gui/SmartPickupFieldSiloTab.lua Underfanen: indstillinger + status
Create tests/test_adFieldSiloPlanner.lua, tests/test_adFieldSiloConfig.lua, tests/test_adFieldSilos.lua Tests
Modify FS25_ADSmartPickup/modDesc.xml sourceFiles + version
Modify FS25_ADSmartPickup/adSmartPickup.lua tick + source af fane-fil
Modify FS25_ADSmartPickup/adFieldWork.lua:133 busy inkluderer lån
Modify FS25_ADSmartPickup/adRunsController.lua (save/load) gem/indlæs opsætning
Modify FS25_ADSmartPickup/gui/SmartPickupFrame.lua fane-konstanter, rebuild, knapper, klik-routing
Modify FS25_ADSmartPickup/l10n/l10n_da.xml, l10n_en.xml tekster

Task 1: Planlæggeren

Files:

  • Create: FS25_ADSmartPickup/adFieldSiloPlanner.lua
  • Test: tests/test_adFieldSiloPlanner.lua

Interfaces:

  • Produces: ADFieldSiloPlanner.key(siloId, fillType) -> string; ADFieldSiloPlanner.plan(input) -> {dispatch = {{vehicleId, siloId, siloMarkerId, fillType, storeId, storeMarkerId, liters}}, status = {{siloId, siloName, fillType, liters, onWay, state}}, freeSlots} hvor input = {silos = {{id, name, markerId|nil, x, z, stock = {[fillType] = liters}}}, stores = {{id, markerId, x, z, free = {[fillType] = liters}}}, loans = {{siloId, fillType, liters}}, vehicles = {{id, x, z, capacity, carries = {[fillType] = true}, cargo = fillType|nil}}, paused = {[key] = true}, maxVehicles, minLiters}; state ∈ "ok" | "noMarker" | "noFarmStore" | "paused".

  • Step 1: Write the failing test — tests/test_adFieldSiloPlanner.lua:

-- Kør: luajit tests/test_adFieldSiloPlanner.lua   (fra repo-roden)
dofile("FS25_ADSmartPickup/adFieldSiloPlanner.lua")
local P = ADFieldSiloPlanner
local WHEAT, CANOLA = 1, 2
local failures = 0
local function check(name, actual, expected)
    if actual == expected then print("OK   " .. name) else
        failures = failures + 1
        print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
    end
end
local function silo(id, x, stock, markerId) return {id = id, name = id, markerId = markerId == nil and (id .. "m") or markerId or nil, x = x, z = 0, stock = stock} end
local function store(id, x, free) return {id = id, markerId = id .. "m", x = x, z = 0, free = free} end
local function vehicle(id, x, capacity, carries, cargo) return {id = id, x = x, z = 0, capacity = capacity, carries = carries or {[WHEAT] = true, [CANOLA] = true}, cargo = cargo} end
local function base(over)
    local input = {silos = {}, stores = {store("farm", 0, {[WHEAT] = 1000000, [CANOLA] = 1000000})}, loans = {}, vehicles = {}, paused = {}, maxVehicles = 2, minLiters = 10000}
    for k, v in pairs(over or {}) do input[k] = v end
    return input
end

-- kvalificering
local r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 9999})}, vehicles = {vehicle("v1", 400, 50000)}}))
check("under tærskel -> ingen afsendelse", #r.dispatch, 0)
check("under tærskel -> ingen status", #r.status, 0)
r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 30000}, false)}, vehicles = {vehicle("v1", 400, 50000)}}))
check("uden markør -> noMarker", r.status[1].state, "noMarker")
check("uden markør -> ingen afsendelse", #r.dispatch, 0)
r = P.plan(base({stores = {store("farm", 0, {[CANOLA] = 1000000})}, silos = {silo("A", 500, {[WHEAT] = 30000})}, vehicles = {vehicle("v1", 400, 50000)}}))
check("ingen gård tager varen -> noFarmStore", r.status[1].state, "noFarmStore")
r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 30000})}, vehicles = {vehicle("v1", 400, 50000)}, paused = {[P.key("A", WHEAT)] = true}}))
check("pause -> paused, ingen afsendelse", r.status[1].state .. #r.dispatch, "paused0")

-- afsendelse, fuldeste først, x-loft
r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 20000}), silo("B", 900, {[WHEAT] = 80000})}, vehicles = {vehicle("v1", 400, 50000), vehicle("v2", 950, 50000), vehicle("v3", 1000, 50000)}}))
check("x=2 -> to afsendt", #r.dispatch, 2)
check("fuldeste silo først", r.dispatch[1].siloId, "B")
check("nærmeste vogn til B", r.dispatch[1].vehicleId, "v2")
check("B's rest (30000) får vogn nr. 2", r.dispatch[2].siloId, "B")
check("lager-markør med", r.dispatch[1].storeMarkerId, "farmm")
check("liter = vognens kapacitet", r.dispatch[1].liters, 50000)
check("anden tur = resten", r.dispatch[2].liters, 30000)

-- aktive lån tæller mod x og mod siloens rest
r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 60000})}, loans = {{siloId = "A", fillType = WHEAT, liters = 50000}}, vehicles = {vehicle("v1", 400, 50000), vehicle("v2", 400, 50000)}}))
check("lån på vej -> rest 10000 -> én ny vogn", #r.dispatch, 1)
check("status viser på vej", r.status[1].onWay, 50000)
r = P.plan(base({maxVehicles = 1, silos = {silo("A", 500, {[WHEAT] = 90000})}, loans = {{siloId = "Z", fillType = WHEAT, liters = 1}}, vehicles = {vehicle("v1", 400, 50000)}}))
check("x=1 med ét lån ude -> ingen", #r.dispatch, 0)
check("frie pladser 0", r.freeSlots, 0)

-- flere varer i én silo = hver sin opgave
r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 40000, [CANOLA] = 20000})}, vehicles = {vehicle("v1", 400, 50000), vehicle("v2", 410, 50000)}}))
check("to varer -> to afsendelser", #r.dispatch, 2)
check("fuldeste vare først", r.dispatch[1].fillType, WHEAT)

-- vogne der ikke kan
r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 40000})}, vehicles = {vehicle("v1", 400, 50000, {[CANOLA] = true}), vehicle("v2", 2000, 50000)}}))
check("vogn der ikke kan bære varen springes over", r.dispatch[1].vehicleId, "v2")
r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 40000})}, vehicles = {vehicle("v1", 400, 50000, nil, CANOLA), vehicle("v2", 2000, 50000)}}))
check("vogn med anden vare på springes over", r.dispatch[1].vehicleId, "v2")
r = P.plan(base({silos = {silo("A", 500, {[WHEAT] = 40000})}, vehicles = {vehicle("v1", 400, 0)}}))
check("vogn uden kapacitet -> ingen", #r.dispatch, 0)

-- lagervalg
r = P.plan(base({stores = {store("near", 400, {[WHEAT] = 20000}), store("far", 5000, {[WHEAT] = 900000})}, silos = {silo("A", 500, {[WHEAT] = 90000})}, vehicles = {vehicle("v1", 400, 50000), vehicle("v2", 400, 50000)}}))
check("nærmeste lager uden plads til læsset -> det med plads", r.dispatch[1].storeId, "far")
r = P.plan(base({stores = {store("small", 400, {[WHEAT] = 30000}), store("tiny", 300, {[WHEAT] = 15000})}, silos = {silo("A", 500, {[WHEAT] = 90000})}, vehicles = {vehicle("v1", 400, 50000), vehicle("v2", 400, 50000)}}))
check("intet har plads til hele læsset -> mest plads", r.dispatch[1].storeId, "small")
check("reserveret plads: vogn 2 til det andet lager", r.dispatch[2].storeId, "tiny")
r = P.plan(base({stores = {store("full", 400, {[WHEAT] = 5000})}, silos = {silo("A", 500, {[WHEAT] = 90000})}, vehicles = {vehicle("v1", 400, 50000)}}))
check("lager med under tærsklen -> noFarmStore", r.status[1].state, "noFarmStore")

-- input muteres ikke
local input = base({silos = {silo("A", 500, {[WHEAT] = 90000})}, vehicles = {vehicle("v1", 400, 50000)}})
P.plan(input)
check("input.stores urørt", input.stores[1].free[WHEAT], 1000000)

print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
  • Step 2: Run test to verify it fails

Run: luajit tests/test_adFieldSiloPlanner.lua Expected: FAIL (fil adFieldSiloPlanner.lua findes ikke).

  • Step 3: Write implementation — FS25_ADSmartPickup/adFieldSiloPlanner.lua:
-- AD Field Silo Planner
-- Ren planlægning for marksilo-flowet: hvilke (silo, vare) skal hjem, hvor mange lånte vogne sendes, og til
-- hvilket gårdlager. Ingen spil-kald; input muteres ikke.

ADFieldSiloPlanner = {}

function ADFieldSiloPlanner.key(siloId, fillType)
    return tostring(siloId) .. ":" .. tostring(fillType)
end
local key = ADFieldSiloPlanner.key

local function dist2(a, b)
    return ((a.x or 0) - (b.x or 0)) ^ 2 + ((a.z or 0) - (b.z or 0)) ^ 2
end

local function freeIn(store, fillType, reserved)
    return ((store.free or {})[fillType] or 0) - (reserved[store.id .. ":" .. tostring(fillType)] or 0)
end

local function anyStoreTakes(stores, fillType, minLiters, reserved)
    for _, store in ipairs(stores or {}) do
        if store.markerId ~= nil and freeIn(store, fillType, reserved) >= minLiters then return true end
    end
    return false
end

-- Nærmeste lager med plads til hele turen; ellers det med mest plads (over tærsklen).
local function chooseStore(stores, silo, fillType, liters, minLiters, reserved)
    local best, bestD, most, mostFree = nil, nil, nil, nil
    for _, store in ipairs(stores or {}) do
        local free = freeIn(store, fillType, reserved)
        if store.markerId ~= nil and free >= minLiters then
            local d = dist2(store, silo)
            if free >= liters and (bestD == nil or d < bestD) then best, bestD = store, d end
            if mostFree == nil or free > mostFree then most, mostFree = store, free end
        end
    end
    return best or most
end

local function canCarry(vehicle, fillType)
    if (vehicle.capacity or 0) <= 0 then return false end
    if vehicle.cargo ~= nil and vehicle.cargo ~= fillType then return false end
    return (vehicle.carries or {})[fillType] == true
end

local function nearestVehicle(vehicles, used, silo, fillType)
    local best, bestD = nil, nil
    for _, vehicle in ipairs(vehicles or {}) do
        if not used[vehicle.id] and canCarry(vehicle, fillType) then
            local d = dist2(vehicle, silo)
            if bestD == nil or d < bestD then best, bestD = vehicle, d end
        end
    end
    return best
end

function ADFieldSiloPlanner.plan(input)
    local minLiters = input.minLiters or 10000
    local reserved, onWay = {}, {}
    for _, loan in ipairs(input.loans or {}) do
        local k = key(loan.siloId, loan.fillType)
        onWay[k] = (onWay[k] or 0) + (loan.liters or 0)
    end
    local status, tasks = {}, {}
    for _, silo in ipairs(input.silos or {}) do
        for fillType, liters in pairs(silo.stock or {}) do
            if (liters or 0) >= minLiters then
                local k = key(silo.id, fillType)
                local entry = {siloId = silo.id, siloName = silo.name, fillType = fillType, liters = liters, onWay = onWay[k] or 0}
                if silo.markerId == nil then
                    entry.state = "noMarker"
                elseif not anyStoreTakes(input.stores, fillType, minLiters, reserved) then
                    entry.state = "noFarmStore"
                elseif (input.paused or {})[k] then
                    entry.state = "paused"
                else
                    entry.state = "ok"
                    local rest = liters - entry.onWay
                    if rest >= minLiters then table.insert(tasks, {silo = silo, fillType = fillType, rest = rest}) end
                end
                table.insert(status, entry)
            end
        end
    end
    table.sort(tasks, function(a, b)
        if a.rest ~= b.rest then return a.rest > b.rest end
        if tostring(a.silo.id) ~= tostring(b.silo.id) then return tostring(a.silo.id) < tostring(b.silo.id) end
        return a.fillType < b.fillType
    end)
    table.sort(status, function(a, b)
        if tostring(a.siloId) ~= tostring(b.siloId) then return tostring(a.siloId) < tostring(b.siloId) end
        return a.fillType < b.fillType
    end)
    local freeSlots = math.max(0, (input.maxVehicles or 2) - #(input.loans or {}))
    local used, dispatch = {}, {}
    for _, task in ipairs(tasks) do
        while freeSlots > 0 and task.rest >= minLiters do
            local vehicle = nearestVehicle(input.vehicles, used, task.silo, task.fillType)
            if vehicle == nil then break end
            local liters = math.min(vehicle.capacity, task.rest)
            local store = chooseStore(input.stores, task.silo, task.fillType, liters, minLiters, reserved)
            if store == nil then break end
            used[vehicle.id] = true
            local reserveKey = store.id .. ":" .. tostring(task.fillType)
            reserved[reserveKey] = (reserved[reserveKey] or 0) + liters
            table.insert(dispatch, {vehicleId = vehicle.id, siloId = task.silo.id, siloMarkerId = task.silo.markerId,
                fillType = task.fillType, storeId = store.id, storeMarkerId = store.markerId, liters = liters})
            task.rest = task.rest - liters
            freeSlots = freeSlots - 1
        end
    end
    return {dispatch = dispatch, status = status, freeSlots = freeSlots}
end
  • Step 4: Run test to verify it passes

Run: luajit tests/test_adFieldSiloPlanner.lua Expected: ALLE TESTS OK

  • Step 5: Commit
git add FS25_ADSmartPickup/adFieldSiloPlanner.lua tests/test_adFieldSiloPlanner.lua
git commit -m "feat(marksilo): ren planlægger — kvalificering, fuldeste først, x-loft, vogn- og lagervalg med reservation"

Task 2: Opsætningsmodellen

Files:

  • Create: FS25_ADSmartPickup/adFieldSiloConfig.lua
  • Test: tests/test_adFieldSiloConfig.lua

Interfaces:

  • Produces: ADFieldSiloConfig.new() -> cfg ({enabled=false, storeIds={}, maxVehicles=2, minLiters=10000}); ADFieldSiloConfig.with(cfg, changes) -> cfg (changes ⊆ {enabled, maxVehicles, minLiters}; clamps); ADFieldSiloConfig.toggleStore(cfg, id) -> cfg; ADFieldSiloConfig.hasStore(cfg, id) -> bool; ADFieldSiloConfig.MIN_LITERS_CHOICES = {5000, 10000, 25000, 50000}; ADFieldSiloConfig.MAX_VEHICLES = 10; ADFieldSiloConfig.toRecord(cfg) -> {enabled=0|1, stores="id id", maxVehicles, minLiters}; ADFieldSiloConfig.fromRecord(record) -> cfg.

  • Step 1: Write the failing test — tests/test_adFieldSiloConfig.lua:

-- Kør: luajit tests/test_adFieldSiloConfig.lua   (fra repo-roden)
dofile("FS25_ADSmartPickup/adFieldSiloConfig.lua")
local C = ADFieldSiloConfig
local failures = 0
local function check(name, actual, expected)
    if actual == expected then print("OK   " .. name) else
        failures = failures + 1
        print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
    end
end
local cfg = C.new()
check("standard fra", cfg.enabled, false)
check("standard x=2", cfg.maxVehicles, 2)
check("standard tærskel 10000", cfg.minLiters, 10000)
check("x under 1 -> 1", C.with(cfg, {maxVehicles = 0}).maxVehicles, 1)
check("x over 10 -> 10", C.with(cfg, {maxVehicles = 99}).maxVehicles, 10)
check("tærskel snappes til nærmeste valg", C.with(cfg, {minLiters = 12000}).minLiters, 10000)
check("tærskel 40000 -> 50000", C.with(cfg, {minLiters = 40000}).minLiters, 50000)
check("with muterer ikke", cfg.maxVehicles, 2)
local on = C.toggleStore(cfg, "silo1")
check("toggle til", C.hasStore(on, "silo1"), true)
check("toggle muterer ikke", C.hasStore(cfg, "silo1"), false)
check("toggle fra", C.hasStore(C.toggleStore(on, "silo1"), "silo1"), false)
local full = C.with(C.toggleStore(C.toggleStore(cfg, "a"), "b"), {enabled = true, maxVehicles = 4, minLiters = 25000})
local back = C.fromRecord(C.toRecord(full))
check("record: enabled", back.enabled, true)
check("record: lagre", table.concat(back.storeIds, " "), "a b")
check("record: x", back.maxVehicles, 4)
check("record: tærskel", back.minLiters, 25000)
check("tom record -> standard", C.fromRecord(nil).maxVehicles, 2)
check("record uden tal -> standard tærskel", C.fromRecord({enabled = 1}).minLiters, 10000)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
  • Step 2: Run test to verify it fails

Run: luajit tests/test_adFieldSiloConfig.lua — Expected: FAIL (fil findes ikke).

  • Step 3: Write implementation — FS25_ADSmartPickup/adFieldSiloConfig.lua:
-- AD Field Silo Config
-- Marksilo-flowets opsætning som ren model (ny tabel ud, input muteres ikke) + flade records til XML-laget.

ADFieldSiloConfig = {}

ADFieldSiloConfig.MAX_VEHICLES = 10
ADFieldSiloConfig.DEFAULT_MAX_VEHICLES = 2
ADFieldSiloConfig.MIN_LITERS_CHOICES = {5000, 10000, 25000, 50000}
ADFieldSiloConfig.DEFAULT_MIN_LITERS = 10000

local function copy(cfg)
    local ids = {}
    for index, id in ipairs(cfg.storeIds or {}) do ids[index] = id end
    return {enabled = cfg.enabled == true, storeIds = ids, maxVehicles = cfg.maxVehicles, minLiters = cfg.minLiters}
end

local function clampVehicles(value)
    value = tonumber(value) or ADFieldSiloConfig.DEFAULT_MAX_VEHICLES
    return math.max(1, math.min(ADFieldSiloConfig.MAX_VEHICLES, math.floor(value)))
end

local function snapLiters(value)
    value = tonumber(value)
    if value == nil then return ADFieldSiloConfig.DEFAULT_MIN_LITERS end
    local best, bestDiff = nil, nil
    for _, choice in ipairs(ADFieldSiloConfig.MIN_LITERS_CHOICES) do
        local diff = math.abs(choice - value)
        if bestDiff == nil or diff < bestDiff then best, bestDiff = choice, diff end
    end
    return best
end

function ADFieldSiloConfig.new()
    return {enabled = false, storeIds = {}, maxVehicles = ADFieldSiloConfig.DEFAULT_MAX_VEHICLES, minLiters = ADFieldSiloConfig.DEFAULT_MIN_LITERS}
end

function ADFieldSiloConfig.with(cfg, changes)
    local new = copy(cfg)
    if changes.enabled ~= nil then new.enabled = changes.enabled == true end
    if changes.maxVehicles ~= nil then new.maxVehicles = clampVehicles(changes.maxVehicles) end
    if changes.minLiters ~= nil then new.minLiters = snapLiters(changes.minLiters) end
    return new
end

function ADFieldSiloConfig.hasStore(cfg, id)
    for _, storeId in ipairs(cfg.storeIds or {}) do
        if storeId == id then return true end
    end
    return false
end

function ADFieldSiloConfig.toggleStore(cfg, id)
    local new = copy(cfg)
    local kept, found = {}, false
    for _, storeId in ipairs(new.storeIds) do
        if storeId == id then found = true else table.insert(kept, storeId) end
    end
    if not found then table.insert(kept, id) end
    new.storeIds = kept
    return new
end

function ADFieldSiloConfig.toRecord(cfg)
    return {enabled = cfg.enabled and 1 or 0, stores = table.concat(cfg.storeIds or {}, " "),
        maxVehicles = cfg.maxVehicles, minLiters = cfg.minLiters}
end

function ADFieldSiloConfig.fromRecord(record)
    local cfg = ADFieldSiloConfig.new()
    if record == nil then return cfg end
    for token in string.gmatch(tostring(record.stores or ""), "%S+") do table.insert(cfg.storeIds, token) end
    cfg.enabled = tonumber(record.enabled) == 1
    cfg.maxVehicles = clampVehicles(record.maxVehicles)
    cfg.minLiters = snapLiters(record.minLiters)
    return cfg
end
  • Step 4: Run test — luajit tests/test_adFieldSiloConfig.lua → ALLE TESTS OK

  • Step 5: Commit

git add FS25_ADSmartPickup/adFieldSiloConfig.lua tests/test_adFieldSiloConfig.lua
git commit -m "feat(marksilo): opsætningsmodel (til/fra, gårdlagre, x 1-10, tærskel) + records"

Task 3: Spil-laget — lån, afsendelse, frigivelse, gem/indlæs

Files:

  • Create: FS25_ADSmartPickup/adFieldSilos.lua
  • Modify: FS25_ADSmartPickup/adFieldWork.lua (eksportér startAd, enableParkAtJobFinished; busy inkluderer lån)
  • Modify: FS25_ADSmartPickup/adSmartPickup.lua (tick i update)
  • Modify: FS25_ADSmartPickup/adRunsController.lua (save/load kalder ADFieldSilos.save/load)
  • Modify: FS25_ADSmartPickup/modDesc.xml (sourceFiles: adFieldSiloPlanner.lua, adFieldSiloConfig.lua, adFieldSilos.lua — efter adFieldWork.lua)
  • Test: tests/test_adFieldSilos.lua

Interfaces:

  • Consumes: ADFieldSiloPlanner.plan/key (Task 1), ADFieldSiloConfig.* (Task 2), ADFieldWork.getRigs(adEnv), ADFields.list(), ADFields.fieldSilos(adEnv, farmId, info), ADBuildings.list(adEnv, farmId), ADBuildings.getMarkers(building, role), ADRunsController.getVehicleId(vehicle), ADRuns.vehicleLabel(vehicle), ADSmartPickup.getFarmId(vehicle).

  • Produces: ADFieldSilos.config (cfg), ADFieldSilos.setConfig(cfg), ADFieldSilos.isLoaned(vehicle) -> bool, ADFieldSilos.tick(adEnv, dt), ADFieldSilos.lastPlan -> plan|nil, ADFieldSilos.loanCount() -> n, ADFieldSilos.stopAll(adEnv), ADFieldSilos.save(), ADFieldSilos.load(), ADFieldSilos.releaseReason(loan, vehicleState, nowMs) -> nil|"done"|"player"|"stuck" (ren, testet), ADFieldWork.startAd(adEnv, vehicle, mode, firstId, secondId), ADFieldWork.enableParkAtJobFinished(vehicle).

  • Step 1: Write the failing test — tests/test_adFieldSilos.lua (tester den rene frigivelsesregel):

-- Kør: luajit tests/test_adFieldSilos.lua   (fra repo-roden)
dofile("FS25_ADSmartPickup/adFieldSiloPlanner.lua")
dofile("FS25_ADSmartPickup/adFieldSiloConfig.lua")
dofile("FS25_ADSmartPickup/adFieldSilos.lua")
local S = ADFieldSilos
local failures = 0
local function check(name, actual, expected)
    if actual == expected then print("OK   " .. name) else
        failures = failures + 1
        print(string.format("FAIL %s: forventede %s, fik %s", name, tostring(expected), tostring(actual)))
    end
end
local loan = {startedMs = 0, lastProgressMs = 0, lastFill = 0}
check("kører, tidligt -> behold", S.releaseReason(loan, {adActive = true, controlled = false, fill = 0, moving = true}, 5000), nil)
check("AD slukket -> done", S.releaseReason(loan, {adActive = false, controlled = false, fill = 0}, 5000), "done")
check("spiller i vognen -> player", S.releaseReason(loan, {adActive = true, controlled = true, fill = 0}, 5000), "player")
check("står stille uden fyld i 120 s -> stuck", S.releaseReason(loan, {adActive = true, controlled = false, fill = 0, moving = false}, 120001), "stuck")
check("kører stadig efter 120 s -> behold", S.releaseReason(loan, {adActive = true, controlled = false, fill = 0, moving = true}, 120001), nil)
local loading = {startedMs = 0, lastProgressMs = 110000, lastFill = 0}
check("fyldning steg for nylig -> behold", S.releaseReason(loading, {adActive = true, controlled = false, fill = 20000, moving = false}, 125000), nil)
print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
  • Step 2: Run test to verify it fails — luajit tests/test_adFieldSilos.lua → FAIL (fil findes ikke).

  • Step 3: Export helpers from adFieldWork — i adFieldWork.lua efter den lokale startAd-funktion (og enableParkAtJobFinished), tilføj:

ADFieldWork.startAd = startAd
ADFieldWork.enableParkAtJobFinished = enableParkAtJobFinished

og i ADFieldWork.getRigs ændr busy-linjen til:

                busy = state.assignments[id] ~= nil or isAdActive(vehicle) or ADCpBridge.isWorking(vehicle)
                    or (ADFieldSilos ~= nil and ADFieldSilos.isLoaned(vehicle)),
  • Step 4: Write implementation — FS25_ADSmartPickup/adFieldSilos.lua:
-- AD Field Silos
-- Marksilo-flowet i spillet: samler marksiloer, gårdlagre og ledige vogne, lader ADFieldSiloPlanner vælge,
-- låner vogne (AutoDrive "Hent og aflever", én tur, parkering bagefter) og frigiver dem igen.
-- Lån ligger kun i hukommelsen; opsætningen gemmes i ADSmartPickup_fieldsilos.xml.

ADFieldSilos = {}

ADFieldSilos.PLAN_INTERVAL_MS = 60000
ADFieldSilos.CHECK_INTERVAL_MS = 2000
ADFieldSilos.STUCK_MS = 120000
ADFieldSilos.PAUSE_MS = 600000
ADFieldSilos.MOVING_SPEED = 0.00028 -- m/ms, samme grænse som AutoDrive
ADFieldSilos.FILE_NAME = "ADSmartPickup_fieldsilos.xml"
ADFieldSilos.ROOT = "ADSmartPickupFieldSilos"
ADFieldSilos.LOG_PREFIX = "ADSmartPickup: marksilo: "

ADFieldSilos.config = ADFieldSiloConfig.new()
ADFieldSilos.lastPlan = nil
local loans = setmetatable({}, {__mode = "k"})   -- vehicle -> {siloId, siloName, fillType, liters, startedMs, lastProgressMs, lastFill}
local paused = {}                                   -- key -> untilMs
local planTimerMs, checkTimerMs, nowMs = ADFieldSilos.PLAN_INTERVAL_MS, 0, 0

-- ---- ren frigivelsesregel ----

-- vehicleState = {adActive, controlled, fill, moving}
function ADFieldSilos.releaseReason(loan, vehicleState, now)
    if vehicleState.controlled then return "player" end
    if not vehicleState.adActive then return "done" end
    if vehicleState.moving then return nil end
    if (vehicleState.fill or 0) > (loan.lastFill or 0) then return nil end
    if now - (loan.lastProgressMs or loan.startedMs or 0) > ADFieldSilos.STUCK_MS then return "stuck" end
    return nil
end

-- ---- opsætning ----

function ADFieldSilos.setConfig(cfg)
    local wasEnabled = ADFieldSilos.config.enabled
    ADFieldSilos.config = cfg
    if wasEnabled and not cfg.enabled and ADFieldSilos.adEnv ~= nil then ADFieldSilos.stopAll(ADFieldSilos.adEnv) end
    planTimerMs = ADFieldSilos.PLAN_INTERVAL_MS -- planlæg straks ved næste tick
end

function ADFieldSilos.isLoaned(vehicle)
    return loans[vehicle] ~= nil
end

function ADFieldSilos.loanCount()
    local n = 0
    for _ in pairs(loans) do n = n + 1 end
    return n
end

-- ---- spil-input ----

local function farmId()
    return g_currentMission ~= nil and g_currentMission.getFarmId ~= nil and g_currentMission:getFarmId() or 1
end

local function position(node)
    if node == nil then return 0, 0 end
    local x, _, z = getWorldTranslation(node)
    return x, z
end

local function siloStock(placeable)
    local stock = {}
    local spec = placeable ~= nil and placeable.spec_silo or nil
    for _, storage in pairs(spec ~= nil and spec.storages or {}) do
        for fillType, level in pairs(storage.fillLevels or {}) do
            if (level or 0) > 0 then stock[fillType] = (stock[fillType] or 0) + level end
        end
    end
    return stock
end

local function siloFree(placeable)
    local free = {}
    local spec = placeable ~= nil and placeable.spec_silo or nil
    for _, storage in pairs(spec ~= nil and spec.storages or {}) do
        for fillType in pairs(storage.fillTypes or {}) do
            local amount = storage.getFreeCapacity ~= nil and (storage:getFreeCapacity(fillType) or 0) or 0
            free[fillType] = (free[fillType] or 0) + amount
        end
    end
    return free
end

local function firstMarker(building, role)
    local markers = ADBuildings.getMarkers(building, role)
    return markers[1] ~= nil and markers[1].id or nil
end

-- Gårdlagre fra opsætningen + marksiloer (alle markers marksiloer, uden dubletter og uden gårdlagre).
function ADFieldSilos.collectBuildings(adEnv)
    local fid = farmId()
    local stores, byId = {}, {}
    for _, building in ipairs(ADBuildings.list(adEnv, fid)) do
        byId[building.id] = building
        if building.kind == "silo" and ADFieldSiloConfig.hasStore(ADFieldSilos.config, building.id) then
            local x, z = position(building.placeable ~= nil and building.placeable.rootNode or nil)
            table.insert(stores, {id = building.id, name = building.name, markerId = firstMarker(building, "unload"),
                x = x, z = z, free = siloFree(building.placeable)})
        end
    end
    local silos, seen = {}, {}
    for _, info in ipairs(ADFields.list()) do
        for _, building in ipairs(ADFields.fieldSilos(adEnv, fid, info)) do
            if not seen[building.id] and not ADFieldSiloConfig.hasStore(ADFieldSilos.config, building.id) then
                seen[building.id] = true
                local x, z = position(building.placeable ~= nil and building.placeable.rootNode or nil)
                table.insert(silos, {id = building.id, name = building.name, markerId = firstMarker(building, "load"),
                    x = x, z = z, stock = siloStock(building.placeable)})
            end
        end
    end
    return silos, stores
end

local function trailerInfo(adEnv, vehicle)
    local capacity, carries, cargo = 0, {}, nil
    for _, unit in ipairs(adEnv.AutoDrive.getAllDischargeableUnits(vehicle) or {}) do
        local object, index = unit.object, unit.fillUnitIndex
        if object ~= nil and object.getFillUnitCapacity ~= nil then
            capacity = capacity + (object:getFillUnitCapacity(index) or 0)
            for fillType in pairs(object.getFillUnitSupportedFillTypes ~= nil and object:getFillUnitSupportedFillTypes(index) or {}) do
                carries[fillType] = true
            end
            if (object:getFillUnitFillLevel(index) or 0) > 0 then cargo = object:getFillUnitFillType(index) end
        end
    end
    return capacity, carries, cargo
end

local function fillLevel(adEnv, vehicle)
    local ok, level = pcall(function() return (adEnv.AutoDrive.getAllFillLevels(adEnv.AutoDrive.getAllUnits(vehicle))) end)
    return ok and tonumber(level) or 0
end

function ADFieldSilos.collectVehicles(adEnv)
    local vehicles, byId = {}, {}
    for _, rig in ipairs(ADFieldWork.getRigs(adEnv)) do
        if rig.role == "unloader" and rig.enabled and not rig.busy and not rig.controlled then
            local capacity, carries, cargo = trailerInfo(adEnv, rig.vehicle)
            table.insert(vehicles, {id = rig.id, x = rig.x, z = rig.z, capacity = capacity, carries = carries, cargo = cargo})
            byId[rig.id] = rig.vehicle
        end
    end
    return vehicles, byId
end

-- ---- lån ----

local function fillTypeName(fillType)
    local desc = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeByIndex(fillType) or nil
    return desc ~= nil and (desc.title or desc.name) or tostring(fillType)
end

local function dispatchOne(adEnv, order, vehicle, silosById, storesById)
    local module = vehicle.ad.stateModule
    module:setMode(adEnv.AutoDrive.MODE_PICKUPANDDELIVER)
    module:setFirstMarkerByWayPointId(order.siloMarkerId)
    module:setSecondMarkerByWayPointId(order.storeMarkerId)
    module.selectedFillTypes = {order.fillType}
    module:setFillType(order.fillType)
    module.loopCounter = 1
    if module.setLoopsDone ~= nil then module:setLoopsDone(0) end
    ADFieldWork.enableParkAtJobFinished(vehicle)
    module:raiseDirtyFlag()
    ADFieldWork.startAd(adEnv, vehicle, adEnv.AutoDrive.MODE_PICKUPANDDELIVER, order.siloMarkerId, order.storeMarkerId)
    local silo, store = silosById[order.siloId], storesById[order.storeId]
    loans[vehicle] = {siloId = order.siloId, siloName = silo ~= nil and silo.name or order.siloId, fillType = order.fillType,
        liters = order.liters, startedMs = nowMs, lastProgressMs = nowMs, lastFill = fillLevel(adEnv, vehicle)}
    Logging.info(ADFieldSilos.LOG_PREFIX .. "'%s' henter %d l %s i %s -> %s", tostring(ADRuns.vehicleLabel(vehicle)),
        math.floor(order.liters), fillTypeName(order.fillType), tostring(silo ~= nil and silo.name or order.siloId),
        tostring(store ~= nil and store.name or order.storeId))
end

local function release(adEnv, vehicle, reason)
    local loan = loans[vehicle]
    loans[vehicle] = nil
    if loan == nil then return end
    if reason == "stuck" then
        local k = ADFieldSiloPlanner.key(loan.siloId, loan.fillType)
        paused[k] = nowMs + ADFieldSilos.PAUSE_MS
        pcall(vehicle.stopAutoDrive, vehicle)
        Logging.warning(ADFieldSilos.LOG_PREFIX .. "'%s' kom ikke i gang ved %s (%s) på 2 min — stoppet; siloen springes over i 10 min",
            tostring(ADRuns.vehicleLabel(vehicle)), tostring(loan.siloName), fillTypeName(loan.fillType))
    elseif reason == "stopped" then
        pcall(vehicle.stopAutoDrive, vehicle)
    end
    Logging.info(ADFieldSilos.LOG_PREFIX .. "'%s' frigivet (%s)", tostring(ADRuns.vehicleLabel(vehicle)), tostring(reason))
end

function ADFieldSilos.stopAll(adEnv)
    local list = {}
    for vehicle in pairs(loans) do table.insert(list, vehicle) end
    for _, vehicle in ipairs(list) do release(adEnv, vehicle, "stopped") end
end

local function checkLoans(adEnv)
    local list = {}
    for vehicle in pairs(loans) do table.insert(list, vehicle) end
    for _, vehicle in ipairs(list) do
        local loan = loans[vehicle]
        local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
        local fill = fillLevel(adEnv, vehicle)
        local vehicleState = {
            adActive = stateModule ~= nil and stateModule:isActive(),
            controlled = vehicle.getIsControlled ~= nil and vehicle:getIsControlled() or false,
            fill = fill, moving = (vehicle.lastSpeedReal or 0) >= ADFieldSilos.MOVING_SPEED,
        }
        if vehicleState.moving or fill > (loan.lastFill or 0) then loan.lastProgressMs = nowMs end
        loan.lastFill = math.max(loan.lastFill or 0, fill)
        local reason = ADFieldSilos.releaseReason(loan, vehicleState, nowMs)
        if reason ~= nil then release(adEnv, vehicle, reason) end
    end
end

local function currentPaused()
    local result = {}
    for k, untilMs in pairs(paused) do
        if untilMs > nowMs then result[k] = true else paused[k] = nil end
    end
    return result
end

local function loanList()
    local list = {}
    for _, loan in pairs(loans) do table.insert(list, {siloId = loan.siloId, fillType = loan.fillType, liters = loan.liters}) end
    return list
end

function ADFieldSilos.planNow(adEnv)
    local cfg = ADFieldSilos.config
    local silos, stores = ADFieldSilos.collectBuildings(adEnv)
    local vehicles, vehiclesById = ADFieldSilos.collectVehicles(adEnv)
    local plan = ADFieldSiloPlanner.plan({silos = silos, stores = stores, loans = loanList(), vehicles = vehicles,
        paused = currentPaused(), maxVehicles = cfg.maxVehicles, minLiters = cfg.minLiters})
    ADFieldSilos.lastPlan = plan
    if not cfg.enabled then return plan end
    local silosById, storesById = {}, {}
    for _, silo in ipairs(silos) do silosById[silo.id] = silo end
    for _, store in ipairs(stores) do storesById[store.id] = store end
    for _, order in ipairs(plan.dispatch) do
        local vehicle = vehiclesById[order.vehicleId]
        if vehicle ~= nil then
            local ok, err = pcall(dispatchOne, adEnv, order, vehicle, silosById, storesById)
            if not ok then
                loans[vehicle] = nil
                Logging.warning(ADFieldSilos.LOG_PREFIX .. "kunne ikke sende '%s': %s", tostring(ADRuns.vehicleLabel(vehicle)), tostring(err))
            end
        end
    end
    return plan
end

function ADFieldSilos.tick(adEnv, dt)
    if adEnv == nil then return end
    ADFieldSilos.adEnv = adEnv
    nowMs = nowMs + (dt or 0)
    checkTimerMs = checkTimerMs + (dt or 0)
    if checkTimerMs >= ADFieldSilos.CHECK_INTERVAL_MS then
        checkTimerMs = 0
        checkLoans(adEnv)
    end
    planTimerMs = planTimerMs + (dt or 0)
    if planTimerMs >= ADFieldSilos.PLAN_INTERVAL_MS then
        planTimerMs = 0
        ADFieldSilos.planNow(adEnv)
    end
end

-- ---- gem / indlæs (samme sti-opslag som runs) ----

local function getPath()
    local path = ADRunsStorage ~= nil and ADRunsStorage.getPath() or nil
    if path == nil then return nil end
    return (path:gsub(ADRunsStorage.FILE_NAME .. "$", ADFieldSilos.FILE_NAME))
end

function ADFieldSilos.save()
    local path = getPath()
    if path == nil then return false end
    local xmlFile = XMLFile.create("ADSmartPickupFieldSilos", path, ADFieldSilos.ROOT)
    if xmlFile == nil then return false end
    local record = ADFieldSiloConfig.toRecord(ADFieldSilos.config)
    xmlFile:setInt(ADFieldSilos.ROOT .. "#enabled", record.enabled)
    xmlFile:setString(ADFieldSilos.ROOT .. "#stores", record.stores)
    xmlFile:setInt(ADFieldSilos.ROOT .. "#maxVehicles", record.maxVehicles)
    xmlFile:setInt(ADFieldSilos.ROOT .. "#minLiters", record.minLiters)
    xmlFile:save()
    xmlFile:delete()
    return true
end

function ADFieldSilos.load()
    local path = getPath()
    if path == nil or not fileExists(path) then
        ADFieldSilos.config = ADFieldSiloConfig.new()
        return
    end
    local xmlFile = XMLFile.load("ADSmartPickupFieldSilos", path)
    if xmlFile == nil then
        ADFieldSilos.config = ADFieldSiloConfig.new()
        return
    end
    ADFieldSilos.config = ADFieldSiloConfig.fromRecord({
        enabled = xmlFile:getInt(ADFieldSilos.ROOT .. "#enabled"),
        stores = xmlFile:getString(ADFieldSilos.ROOT .. "#stores"),
        maxVehicles = xmlFile:getInt(ADFieldSilos.ROOT .. "#maxVehicles"),
        minLiters = xmlFile:getInt(ADFieldSilos.ROOT .. "#minLiters"),
    })
    xmlFile:delete()
end
  • Step 5: Wire tick, save, load, modDesc

adSmartPickup.lua — i ADSmartPickup:update(dt), lige efter ADFieldJobs.tick-blokken:

    if ADFieldSilos ~= nil then
        local okSilos, errSilos = pcall(ADFieldSilos.tick, getAutoDriveEnv(), dt)
        if not okSilos then
            Logging.warning(ADSmartPickup.LOG_PREFIX .. "marksilo-flow fejlede: %s", tostring(errSilos))
        end
    end

adRunsController.lua — i ADRunsController.save() efter pcall(ADFieldJobs.save)-blokken: if ADFieldSilos ~= nil then pcall(ADFieldSilos.save) end; i ADRunsController.load(adEnv) efter pcall(ADRunsController.migrateBuildings, adEnv): if ADFieldSilos ~= nil then pcall(ADFieldSilos.load) end.

modDesc.xml — tilføj efter linjen med adFieldWork.lua i <extraSourceFiles>:

        <sourceFile filename="adFieldSiloPlanner.lua"/>
        <sourceFile filename="adFieldSiloConfig.lua"/>
        <sourceFile filename="adFieldSilos.lua"/>
  • Step 6: Run all tests + syntax

Run: for f in FS25_ADSmartPickup/*.lua; do luajit -bl "$f" >/dev/null || echo "SYNTAKS $f"; done; for t in tests/test_*.lua; do luajit $t >/dev/null 2>&1 || echo "FEJLER $t"; done Expected: ingen output.

  • Step 7: Commit
git add FS25_ADSmartPickup/adFieldSilos.lua FS25_ADSmartPickup/adFieldWork.lua FS25_ADSmartPickup/adSmartPickup.lua FS25_ADSmartPickup/adRunsController.lua FS25_ADSmartPickup/modDesc.xml tests/test_adFieldSilos.lua
git commit -m "feat(marksilo): låner ledige tømmevogne hvert minut, AD hent-og-aflever til gårdlager, frigiver ved færdig/spiller/2 min stilstand; opsætning gemmes"

Task 4: Underfanen "Marksiloer"

Files:

  • Create: FS25_ADSmartPickup/gui/SmartPickupFieldSiloTab.lua
  • Modify: FS25_ADSmartPickup/gui/SmartPickupFrame.lua (konstanter, rebuild, refreshButtons, refreshLive, onClickIngredient-routing)
  • Modify: FS25_ADSmartPickup/adSmartPickup.lua (source(ADSmartPickup.dir .. "gui/SmartPickupFieldSiloTab.lua") efter Month-tab)
  • Modify: FS25_ADSmartPickup/l10n/l10n_da.xml, l10n_en.xml
  • Modify: FS25_ADSmartPickup/modDesc.xml (version 1.36.0.0)

Interfaces:

  • Consumes: ADFieldSilos.config/setConfig/lastPlan/loanCount/planNow/save, ADFieldSiloConfig.with/toggleStore/hasStore/MIN_LITERS_CHOICES/MAX_VEHICLES, SmartPickupFrame:addToggleRow(kind, title, texts, isOn), addTextRow, addCellRow(prefab, texts), self.fieldPrefab, ADBuildings.list.

  • Produces: SmartPickupFrame.TAB_FIELDSILOS = 5, SmartPickupFrame:fillFieldSiloRows(adEnv), SmartPickupFrame:onClickFieldSiloRow(state, data), SmartPickupFrame.FIELDSILO_ROW_KINDS.

  • Step 1: Tab constants in SmartPickupFrame.lua — ret konstant-linjerne til:

SmartPickupFrame.TAB_OVERVIEW, SmartPickupFrame.TAB_FLOWS, SmartPickupFrame.TAB_FIELDS, SmartPickupFrame.TAB_MONTH, SmartPickupFrame.TAB_FIELDSILOS = 1, 2, 3, 4, 5
SmartPickupFrame.TAB_NAMES = {"spu_tab_overview", "spu_tab_flows", "spu_tab_fields", "spu_tab_month", "spu_tab_fieldsilos", "spu_tab_factories", "spu_tab_silos", "spu_tab_barns", "spu_tab_sheds"}
SmartPickupFrame.TAB_KINDS = {[6] = "factory", [7] = "silo", [8] = "husbandry", [9] = "objectStorage"}

grep -n "TAB_KINDS\|\[4\]\|\[5\]\|TAB_MONTH" FS25_ADSmartPickup/gui/*.lua og ret enhver hårdkodet fane-indeks-antagelse (fx for tab = 4, 7) til de nye tal.

  • Step 2: Rebuild/buttons/live — i rebuild() efter Month-blokken:
    if self.currentTab == SmartPickupFrame.TAB_FIELDSILOS then
        if adEnv ~= nil then
            self:fillFieldSiloRows(adEnv)
        end
        self.monthRebuildTicks = SmartPickupFrame.MONTH_REBUILD_TICKS
        self:colorRows()
        self.boxLayout:invalidateLayout()
        self:refreshButtons()
        self:restoreFocusIfLost()
        return
    end

i refreshButtons(): elseif self.currentTab == SmartPickupFrame.TAB_FIELDSILOS then wanted = self.monthButtons; i refreshLive() ret betingelsen for Month-genopbygning til if self.currentTab == SmartPickupFrame.TAB_MONTH or self.currentTab == SmartPickupFrame.TAB_FIELDSILOS then.

I onClickIngredient før FIELD_ROW_KINDS-grenen:

    elseif data ~= nil and SmartPickupFrame.FIELDSILO_ROW_KINDS ~= nil and SmartPickupFrame.FIELDSILO_ROW_KINDS[data.kind] then
        return self:onClickFieldSiloRow(state, data)
  • Step 3: Tab file — FS25_ADSmartPickup/gui/SmartPickupFieldSiloTab.lua:
-- Smart Pickup — fanen Marksiloer
-- Opsætning (til/fra, maks. vogne, mindste mængde, gårdlagre) og status for marksilo-flowet.
-- Klik går til onClickFieldSiloRow; reglerne ligger i ADFieldSilos/ADFieldSiloPlanner.

SmartPickupFrame.FIELDSILO_ROW_KINDS = {fsEnabled = true, fsMaxVehicles = true, fsMinLiters = true, fsStore = true}

local function numberTexts(from, to)
    local texts = {}
    for n = from, to do table.insert(texts, tostring(n)) end
    return texts
end

local function literTexts()
    local texts = {}
    for _, liters in ipairs(ADFieldSiloConfig.MIN_LITERS_CHOICES) do table.insert(texts, string.format("%d l", liters)) end
    return texts
end

local function indexOf(list, value)
    for index, entry in ipairs(list) do if entry == value then return index end end
    return 1
end

function SmartPickupFrame:fillFieldSiloRows(adEnv)
    local i18n, cfg = self.i18n, ADFieldSilos.config
    local onOff = {i18n:getText("ui_no"), i18n:getText("ui_yes")}
    self:addToggleRow("fsEnabled", i18n:getText("spu_fs_enabled"), onOff, cfg.enabled)
    local maxOption = self:addToggleRow("fsMaxVehicles", i18n:getText("spu_fs_maxVehicles"), numberTexts(1, ADFieldSiloConfig.MAX_VEHICLES), false)
    maxOption:setState(cfg.maxVehicles, false)
    local minOption = self:addToggleRow("fsMinLiters", i18n:getText("spu_fs_minLiters"), literTexts(), false)
    minOption:setState(indexOf(ADFieldSiloConfig.MIN_LITERS_CHOICES, cfg.minLiters), false)

    local plan = ADFieldSilos.lastPlan
    local waiting = 0
    for _, entry in ipairs(plan ~= nil and plan.status or {}) do if entry.state == "ok" then waiting = waiting + 1 end end
    self:addTextRow(string.format(i18n:getText("spu_fs_status"), ADFieldSilos.loanCount(), cfg.maxVehicles, waiting))
    if #cfg.storeIds == 0 then self:addTextRow(i18n:getText("spu_fs_noStores")) end

    self:addTextRow(i18n:getText("spu_fs_silosTitle"))
    if plan == nil or #plan.status == 0 then
        self:addTextRow("    " .. i18n:getText("spu_fs_nothing"))
    else
        self:addCellRow(self.fieldPrefab, {i18n:getText("spu_fs_col_silo"), i18n:getText("spu_col_crop"), i18n:getText("spu_fs_col_liters"),
            i18n:getText("spu_fs_col_onWay"), i18n:getText("spu_fs_col_state"), ""})
        for _, entry in ipairs(plan.status) do
            local desc = g_fillTypeManager:getFillTypeByIndex(entry.fillType)
            local title = desc ~= nil and (desc.title or desc.name) or tostring(entry.fillType)
            local state = i18n:getText("spu_fs_state_" .. entry.state)
            if entry.state == "noFarmStore" then state = string.format(state, title) end
            self:addCellRow(self.fieldPrefab, {tostring(entry.siloName), title, string.format("%d", math.floor(entry.liters)),
                string.format("%d", math.floor(entry.onWay)), state, ""})
        end
    end

    self:addTextRow(i18n:getText("spu_fs_storesTitle"))
    local farmId = g_currentMission:getFarmId()
    for _, building in ipairs(ADBuildings.list(adEnv, farmId)) do
        if building.kind == "silo" then
            local option = self:addToggleRow("fsStore", tostring(building.name), onOff, ADFieldSiloConfig.hasStore(cfg, building.id))
            self.rowData[option] = {kind = "fsStore", buildingId = building.id}
        end
    end
end

function SmartPickupFrame:onClickFieldSiloRow(state, data)
    self:safe(function(frame)
        local cfg = ADFieldSilos.config
        if data.kind == "fsEnabled" then
            cfg = ADFieldSiloConfig.with(cfg, {enabled = state == SmartPickupFrame.STATE_ON})
        elseif data.kind == "fsMaxVehicles" then
            cfg = ADFieldSiloConfig.with(cfg, {maxVehicles = state})
        elseif data.kind == "fsMinLiters" then
            cfg = ADFieldSiloConfig.with(cfg, {minLiters = ADFieldSiloConfig.MIN_LITERS_CHOICES[state]})
        elseif data.kind == "fsStore" then
            cfg = ADFieldSiloConfig.toggleStore(cfg, data.buildingId)
        end
        ADFieldSilos.setConfig(cfg)
        pcall(ADFieldSilos.save)
        local adEnv = frame.getAdEnv()
        if adEnv ~= nil then pcall(ADFieldSilos.planNow, adEnv) end
        frame:requestRebuild()
    end)
end

(addToggleRow gemmer {kind = kind} i rowData via addRow; for gårdlager-rækkerne overskrives den med buildingId som ovenfor.)

  • Step 4: l10n — tilføj i l10n_da.xml (og engelsk i l10n_en.xml) inden </texts>:
        <text name="spu_tab_fieldsilos" text="Marksiloer"/>
        <text name="spu_fs_enabled" text="Hent marksiloer hjem automatisk"/>
        <text name="spu_fs_maxVehicles" text="Maks. vogne ude"/>
        <text name="spu_fs_minLiters" text="Mindste mængde pr. vare"/>
        <text name="spu_fs_status" text="%d/%d vogne ude · %d siloer venter"/>
        <text name="spu_fs_noStores" text="Vælg mindst ét gårdlager nedenfor — ellers køres intet hjem"/>
        <text name="spu_fs_silosTitle" text="Marksiloer med varer"/>
        <text name="spu_fs_nothing" text="Ingen marksiloer har varer over mindste mængde"/>
        <text name="spu_fs_col_silo" text="Silo"/>
        <text name="spu_fs_col_liters" text="Liter"/>
        <text name="spu_fs_col_onWay" text="På vej"/>
        <text name="spu_fs_col_state" text="Status"/>
        <text name="spu_fs_state_ok" text="ok"/>
        <text name="spu_fs_state_noMarker" text="mangler markør"/>
        <text name="spu_fs_state_noFarmStore" text="ingen gård tager %s"/>
        <text name="spu_fs_state_paused" text="pause (kom ikke i gang)"/>
        <text name="spu_fs_storesTitle" text="Gårdlagre (hvor varerne køres hjem til)"/>

Engelsk: Field silos, Bring field silos home automatically, Max. vehicles out, Minimum amount per product, %d/%d vehicles out · %d silos waiting, Select at least one farm storage below — otherwise nothing is brought home, Field silos with products, No field silo has products above the minimum amount, Silo, Liters, On the way, Status, ok, missing marker, no farm storage takes %s, paused (did not get going), Farm storages (where products are brought home to).

  • Step 5: Source the tab file — i adSmartPickup.lua efter source(ADSmartPickup.dir .. "gui/SmartPickupMonthTab.lua"):
    source(ADSmartPickup.dir .. "gui/SmartPickupFieldSiloTab.lua")
  • Step 6: Version, syntax, tests, build

modDesc.xml: <version>1.36.0.0</version>. Run: for f in FS25_ADSmartPickup/*.lua FS25_ADSmartPickup/gui/*.lua; do luajit -bl "$f" >/dev/null || echo "SYNTAKS $f"; done; for t in tests/test_*.lua; do luajit $t >/dev/null 2>&1 || echo "FEJLER $t"; done; xmllint --noout FS25_ADSmartPickup/l10n/*.xml FS25_ADSmartPickup/modDesc.xml Expected: ingen output. Byg: rm -f FS25_ADSmartPickup.zip && (cd FS25_ADSmartPickup && zip -qr ../FS25_ADSmartPickup.zip .) && cp FS25_ADSmartPickup.zip ~/FS25-data/mods/

  • Step 7: Commit
git add FS25_ADSmartPickup tests
git commit -m "feat(gui): fanen Marksiloer — til/fra, maks. vogne, mindste mængde, gårdlagre, status pr. silo (v1.36.0.0)"

Self-Review (udført)

  • Spec-dækning: marksilo-definition (Task 3 collectBuildings), gårdlagre ekskluderet (Task 3), kvalificering/tærskel/rækkefølge/x/lagervalg (Task 1), lån/afsendelse/parkering/frigivelse/vagt/pause (Task 3), ikke-persistente lån (Task 3), markflåde-busy (Task 3 step 3), GUI + status + tekster (Task 4), opsætning gemmes (Task 2+3). Ingen huller.
  • Navne: ADFieldSiloPlanner.plan/key, ADFieldSiloConfig.new/with/toggleStore/hasStore/toRecord/fromRecord, ADFieldSilos.tick/isLoaned/setConfig/planNow/lastPlan/loanCount/save/load/releaseReason — brugt ens i alle tasks.
  • Review Focus-linjerne har tests i Task 1 (anden vare, reservation, flere varer) og Task 3 (spiller, frakobling via setConfig → stopAll; releaseReason "player").