# 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`: ```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`: ```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** ```bash 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`: ```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`: ```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** ```bash 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): ```lua -- 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: ```lua ADFieldWork.startAd = startAd ADFieldWork.enableParkAtJobFinished = enableParkAtJobFinished ``` og i `ADFieldWork.getRigs` ændr `busy`-linjen til: ```lua 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`: ```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: ```lua 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 ``: ```xml ``` - [ ] **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** ```bash 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: ```lua 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: ```lua 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: ```lua 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`: ```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 ``: ```xml ``` 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")`: ```lua source(ADSmartPickup.dir .. "gui/SmartPickupFieldSiloTab.lua") ``` - [ ] **Step 6: Version, syntax, tests, build** `modDesc.xml`: `1.36.0.0`. 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** ```bash 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").