Files
fs25-adsmartpickup/docs/superpowers/plans/2026-09-22-runs-ui.md

128 KiB
Raw Permalink Blame History

Runs-UI (v1.8) + læs-bytte fra Wait (v1.9) — implementeringsplan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: En ESC-menu-fane "Smart Pickup" hvor spilleren definerer runs (mål + varer + Wait-pulje + traktorer + ture) og starter/stopper dem; bagefter (v1.9) skal en traktor der venter ved et Wait-punkt med en vare målet ikke mangler, aflæsse resten i en silo og hente det målet mangler.

Architecture: Et run-lag oven på AutoDrives tilstand. adRuns.lua er rene funktioner (store → ny store), adRunsStorage.lua skriver/læser savegameN/ADSmartPickup_runs.xml, adRunsController.lua oversætter et run til AD-tilstand pr. traktor (Start/Stop, validering) og svarer på "hvilket run hører denne traktor til". De eksisterende hooks (choosePickup, adUnloadWait) læser run'et to steder. GUI'et (gui/SmartPickupFrame.lua/.xml) er en TabbedMenuFrameElement indsat i InGameMenu, der kun kalder controlleren. v1.9 er et nyt modul adLoadSwap.lua, som adUnloadWait kalder mens en traktor venter.

Tech Stack: FS25 Lua (LuaJIT / Lua 5.1), AutoDrive-API via global FS25_AutoDrive, FS25 XMLFile, FS25 GUI (g_gui, TabbedMenuFrameElement, SmoothList, MultiTextOption). Tests med luajit + de eksisterende mock-mønstre i tests/.

Spec: docs/superpowers/specs/2026-09-22-runs-ui-design.md (sektion 1–3 = v1.8; "Efterfølgende" = v1.9).

Global Constraints

  • Sprog i kode-kommentarer, log-tekster og dansk l10n: dansk; l10n også engelsk (l10n_en.xml).
  • Al kode der rører spil-/AD-API køres i pcall; fejl logges med præfiks "ADSmartPickup: " (Logging.warning); moden må aldrig crashe spillet.
  • Ingen nye tastaturgenveje. Singleplayer (ingen MP-events).
  • En traktor kan kun være i ét run; tildeles den et andet, flyttes den.
  • Runs der kørte ved gem genstartes IKKE ved load.
  • Ukendte køretøjs-referencer i XML droppes med loglinje; run'et beholdes.
  • Traktorer uden run opfører sig præcis som i v1.7.2.
  • Test: cd ~/fs25-adsmartpickup && luajit tests/test_adSupplyPlanner.lua && luajit tests/test_adRuns.lua && luajit tests/test_adRunsController.lua && luajit tests/test_adSmartPickup.lua — alle skal ende med ALLE TESTS OK.
  • Byg: cd ~/fs25-adsmartpickup/FS25_ADSmartPickup && rm -f ../FS25_ADSmartPickup.zip && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/.
  • Git: commit-format <type>: <beskrivelse>; INGEN Co-Authored-By-trailer. Push: cd ~/fs25-adsmartpickup && TOKEN=$(grep -A8 -i "gitea" ~/credentials-vault.md | grep -oE '[0-9a-f]{40}' | head -1); git -c http.extraHeader="Authorization: token $TOKEN" push -q gitea HEAD — token må aldrig vises i chat/log/commit.
  • Versioner: v1.8.0.0 efter Task 6, v1.9.0.0 efter Task 8 (modDesc.xml <version>).
  • AD-konstanter (verificeret i FS25_AutoDrive): AutoDrive.MODE_PICKUPANDDELIVER = 2, RT_NONE=1, RT_ONLYPICKUP=2; AutoDrive.settings.useFolders.isVehicleSpecific = false (global), AutoDrive.settings.rotateTargets.isVehicleSpecific = true; AutoDrive.getSetting returnerer setting.values[setting.current]; loop-tæller sættes med stateModule.loopCounter = n; stateModule:raiseDirtyFlag(); stateModule:setLoopsDone(0); køretøjs-liste AutoDrive.getAllVehicles(); ADGraphManager:getDistanceFromNetwork(vehicle); ADGraphManager:getMapMarkerByWayPointId(id); savegame-sti g_currentMission.missionInfo.savegameDirectory; AD gemmer via ItemSystem.save = Utils.prependedFunction(ItemSystem.save, ...); køretøjer har vehicle.uniqueId (streng "vehicle…", gemmes i vehicles.xml).

Filstruktur

Fil Ansvar
FS25_ADSmartPickup/adRuns.lua (ny) Ren run-model: opret/slet/opdatér/tildel/flyt, records ↔ store
FS25_ADSmartPickup/adRunsStorage.lua (ny) XMLFile-læs/skriv af records, sti i savegame
FS25_ADSmartPickup/adRunsController.lua (ny) Køretøjs-id, farmens AD-køretøjer, Auto-vareliste, validering, Start/Stop, status, opslag run↔traktor, gem/indlæs-hooks
FS25_ADSmartPickup/adSmartPickup.lua (ændres) Eksponerer stations-/niveau-opslag; choosePickup tager ingredienser fra run; getIngredientsFor (v1.9); loadMap indlæser runs
FS25_ADSmartPickup/adUnloadWait.lua (ændres) Wait-kandidater filtreret på run'ets pulje; kalder ADLoadSwap mens der ventes (v1.9)
FS25_ADSmartPickup/adLoadSwap.lua (ny, v1.9) Find silo til rest-læs + afgør om bytte er relevant
FS25_ADSmartPickup/gui/SmartPickupFrame.lua, gui/SmartPickupFrame.xml, gui/guiProfiles.xml (nye) ESC-fanen
FS25_ADSmartPickup/l10n/l10n_da.xml, l10n_en.xml (nye) Tekster
tests/test_adRuns.lua, tests/test_adRunsController.lua (nye), tests/test_adSmartPickup.lua (udvides) luajit-tests

Afvigelse fra spec'ens filliste: XML ligger i egen fil adRunsStorage.lua (holder controlleren under 400 linjer), og runMembership er droppet — "hører traktoren til et run" slås op i store'en via køretøjs-id (enklere, og AD-start fra HUD på en run-traktor giver samme adfærd som Start fra UI'et).


Task 1: Run-model (adRuns.lua)

Files:

  • Create: FS25_ADSmartPickup/adRuns.lua
  • Test: tests/test_adRuns.lua

Interfaces:

  • Produces (alle rene; input muteres aldrig):

    • ADRuns.newStore() -> store hvor store = {nextId = number, runs = {run, ...}}, run = {id, name, targetWayPointId|nil, ingredientMode = "auto"|"manual", ingredients = {"STRAW", ...}, waitPoolGroup = "", loops = 0, vehicleIds = {"vehicle…", ...}}
    • ADRuns.createRun(store, name) -> newStore, run
    • ADRuns.getRun(store, runId) -> run|nil
    • ADRuns.deleteRun(store, runId) -> newStore
    • ADRuns.updateRun(store, runId, changes) -> newStore (changes delmængde af {name, targetWayPointId, ingredientMode, ingredients, waitPoolGroup, loops}; targetWayPointId = false rydder målet; loops klippes til 0..99)
    • ADRuns.findRunByVehicle(store, vehicleId) -> run|nil
    • ADRuns.assignVehicle(store, runId, vehicleId) -> newStore, previousRunId|nil
    • ADRuns.unassignVehicle(store, vehicleId) -> newStore
    • ADRuns.toRecords(store) -> {{id, name, target, ingredientMode, ingredients = "STRAW FORAGE", waitPool, loops, vehicleIds}, ...}
    • ADRuns.fromRecords(records) -> store
    • ADRuns.parseIngredients(text) -> list, ADRuns.serializeIngredients(list) -> text
    • ADRuns.MAX_LOOPS = 99, ADRuns.XML_VERSION = 1
  • Step 1: Skriv de fejlende tests

tests/test_adRuns.lua:

-- Kør: luajit tests/test_adRuns.lua   (fra repo-roden)
-- Tester run-modellen (rene funktioner) og XML-laget mod en XMLFile-mock.

dofile("FS25_ADSmartPickup/adRuns.lua")

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

-- R1: opret run -> løbenummer, standardværdier; input-store røres ikke
local empty = ADRuns.newStore()
local s1, cow = ADRuns.createRun(empty, "Cow Feed")
check("R1 id 1", cow.id, 1)
check("R1 navn", cow.name, "Cow Feed")
check("R1 auto-varer som standard", cow.ingredientMode, "auto")
check("R1 loops 0", cow.loops, 0)
check("R1 tom Wait-pulje", cow.waitPoolGroup, "")
check("R1 input-store urørt", #empty.runs, 0)
check("R1 nextId tæller op", s1.nextId, 2)
local s2, pig = ADRuns.createRun(s1, "Pig Feed")
check("R1 andet run får id 2", pig.id, 2)
check("R1 to runs", #s2.runs, 2)

-- R2: opdatér felter; ukendt mode ignoreres; loops klippes
local s3 = ADRuns.updateRun(s2, 1, {name = "Cow 1", targetWayPointId = 94, ingredientMode = "manual", ingredients = {"STRAW", "FORAGE"}, waitPoolGroup = "Cow Wait", loops = 150})
local run1 = ADRuns.getRun(s3, 1)
check("R2 navn", run1.name, "Cow 1")
check("R2 mål", run1.targetWayPointId, 94)
check("R2 manual", run1.ingredientMode, "manual")
check("R2 varer", table.concat(run1.ingredients, ","), "STRAW,FORAGE")
check("R2 pulje", run1.waitPoolGroup, "Cow Wait")
check("R2 loops klippet til 99", run1.loops, 99)
check("R2 gammel store urørt", ADRuns.getRun(s2, 1).name, "Cow Feed")
local s3b = ADRuns.updateRun(s3, 1, {ingredientMode = "bogus", loops = -5})
check("R2 ukendt mode ignoreres", ADRuns.getRun(s3b, 1).ingredientMode, "manual")
check("R2 loops under 0 -> 0", ADRuns.getRun(s3b, 1).loops, 0)
check("R2 ukendt run -> uændret", #ADRuns.updateRun(s3, 99, {name = "x"}).runs, 2)
check("R2 targetWayPointId=false rydder målet", ADRuns.getRun(ADRuns.updateRun(s3, 1, {targetWayPointId = false}), 1).targetWayPointId, nil)
check("R2 nil rører ikke målet", ADRuns.getRun(ADRuns.updateRun(s3, 1, {name = "y"}), 1).targetWayPointId, 94)

-- R3: tildel traktor; samme traktor flyttes mellem runs
local s4, prev = ADRuns.assignVehicle(s3, 1, "vehicleA")
check("R3 tildelt", ADRuns.getRun(s4, 1).vehicleIds[1], "vehicleA")
check("R3 ingen tidligere", prev, nil)
check("R3 opslag", ADRuns.findRunByVehicle(s4, "vehicleA").id, 1)
local s5, prev2 = ADRuns.assignVehicle(s4, 2, "vehicleA")
check("R3 flyttet fra run 1", prev2, 1)
check("R3 væk fra run 1", #ADRuns.getRun(s5, 1).vehicleIds, 0)
check("R3 i run 2", ADRuns.getRun(s5, 2).vehicleIds[1], "vehicleA")
local s5b, prev3 = ADRuns.assignVehicle(s5, 2, "vehicleA")
check("R3 gen-tildeling samme run -> ingen dublet", #ADRuns.getRun(s5b, 2).vehicleIds, 1)
check("R3 gen-tildeling -> ingen 'flyttet fra'", prev3, nil)
local s6 = ADRuns.unassignVehicle(s5, "vehicleA")
check("R3 fjernet", ADRuns.findRunByVehicle(s6, "vehicleA"), nil)
check("R3 ukendt run ved tildeling -> uændret", ADRuns.findRunByVehicle(ADRuns.assignVehicle(s6, 42, "vehicleB"), "vehicleB"), nil)

-- R4: slet run
local s7 = ADRuns.deleteRun(s5, 1)
check("R4 ét run tilbage", #s7.runs, 1)
check("R4 det rigtige", s7.runs[1].id, 2)
check("R4 nextId bevares", s7.nextId, 3)

-- R5: records frem og tilbage
local records = ADRuns.toRecords(s5)
check("R5 target som tal", records[1].target, 94)
check("R5 varer som streng", records[1].ingredients, "STRAW FORAGE")
check("R5 køretøjer", records[2].vehicleIds[1], "vehicleA")
local back = ADRuns.fromRecords(records)
check("R5 to runs tilbage", #back.runs, 2)
check("R5 nextId = højeste + 1", back.nextId, 3)
check("R5 varer parset", ADRuns.getRun(back, 1).ingredients[2], "FORAGE")
check("R5 pulje", ADRuns.getRun(back, 1).waitPoolGroup, "Cow Wait")
check("R5 target 0 -> nil", ADRuns.getRun(back, 2).targetWayPointId, nil)
check("R5 køretøj", ADRuns.findRunByVehicle(back, "vehicleA").id, 2)

-- R6: defekte poster springes over; dubletter af id ignoreres
local dirty = ADRuns.fromRecords({
    {id = "x", name = "Broken"},
    {id = 5, name = "", },
    {id = 7, name = "Ok", target = "12", ingredientMode = "weird", ingredients = "", waitPool = nil, loops = "3", vehicleIds = {}},
    {id = 7, name = "Dublet"},
})
check("R6 kun den gyldige", #dirty.runs, 1)
check("R6 target parses fra streng", dirty.runs[1].targetWayPointId, 12)
check("R6 ukendt mode -> auto", dirty.runs[1].ingredientMode, "auto")
check("R6 tom varestreng -> tom liste", #dirty.runs[1].ingredients, 0)
check("R6 manglende pulje -> tom streng", dirty.runs[1].waitPoolGroup, "")
check("R6 loops fra streng", dirty.runs[1].loops, 3)
check("R6 nextId", dirty.nextId, 8)
check("R6 tom liste", #ADRuns.fromRecords(nil).runs, 0)
check("R6 parseIngredients trimmer", #ADRuns.parseIngredients("  STRAW   FORAGE "), 2)

print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
  • Step 2: Kør testen og se den fejle

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adRuns.lua Forventet: fejler med cannot open FS25_ADSmartPickup/adRuns.lua (filen findes ikke endnu).

  • Step 3: Skriv modellen

FS25_ADSmartPickup/adRuns.lua:

-- AD Runs
-- Rene funktioner (ingen spil-API) for "runs": navn, aflæsningsmål, varer, Wait-pulje,
-- loop-tæller og tildelte traktorer. Alle funktioner returnerer en NY store —
-- input røres aldrig, så GUI og controller kan holde referencer uden overraskelser.

ADRuns = {}

ADRuns.MAX_LOOPS = 99
ADRuns.XML_VERSION = 1
ADRuns.INGREDIENT_MODES = {auto = true, manual = true}

local function copyList(list)
    local copy = {}
    for index, value in ipairs(list or {}) do
        copy[index] = value
    end
    return copy
end

local function copyRun(run)
    return {
        id = run.id,
        name = run.name,
        targetWayPointId = run.targetWayPointId,
        ingredientMode = run.ingredientMode,
        ingredients = copyList(run.ingredients),
        waitPoolGroup = run.waitPoolGroup,
        loops = run.loops,
        vehicleIds = copyList(run.vehicleIds),
    }
end

local function copyStore(store)
    local runs = {}
    for index, run in ipairs(store.runs) do
        runs[index] = copyRun(run)
    end
    return {nextId = store.nextId, runs = runs}
end

local function clampLoops(loops)
    local value = tonumber(loops) or 0
    return math.max(0, math.min(ADRuns.MAX_LOOPS, math.floor(value)))
end

function ADRuns.newStore()
    return {nextId = 1, runs = {}}
end

function ADRuns.createRun(store, name)
    local new = copyStore(store)
    local run = {
        id = new.nextId,
        name = name,
        targetWayPointId = nil,
        ingredientMode = "auto",
        ingredients = {},
        waitPoolGroup = "",
        loops = 0,
        vehicleIds = {},
    }
    new.nextId = new.nextId + 1
    table.insert(new.runs, run)
    return new, run
end

function ADRuns.getRun(store, runId)
    for _, run in ipairs(store.runs) do
        if run.id == runId then
            return run
        end
    end
    return nil
end

function ADRuns.deleteRun(store, runId)
    local new = copyStore(store)
    for index, run in ipairs(new.runs) do
        if run.id == runId then
            table.remove(new.runs, index)
            break
        end
    end
    return new
end

-- changes: delmængde af {name, targetWayPointId, ingredientMode, ingredients, waitPoolGroup, loops}.
function ADRuns.updateRun(store, runId, changes)
    local new = copyStore(store)
    local run = ADRuns.getRun(new, runId)
    if run == nil then
        return new
    end
    if changes.name ~= nil then run.name = changes.name end
    -- false = ryd målet (nil i changes betyder "rør ikke")
    if changes.targetWayPointId == false then
        run.targetWayPointId = nil
    elseif changes.targetWayPointId ~= nil then
        run.targetWayPointId = changes.targetWayPointId
    end
    if ADRuns.INGREDIENT_MODES[changes.ingredientMode] then run.ingredientMode = changes.ingredientMode end
    if changes.ingredients ~= nil then run.ingredients = copyList(changes.ingredients) end
    if changes.waitPoolGroup ~= nil then run.waitPoolGroup = changes.waitPoolGroup end
    if changes.loops ~= nil then run.loops = clampLoops(changes.loops) end
    return new
end

function ADRuns.findRunByVehicle(store, vehicleId)
    for _, run in ipairs(store.runs) do
        for _, id in ipairs(run.vehicleIds) do
            if id == vehicleId then
                return run
            end
        end
    end
    return nil
end

function ADRuns.unassignVehicle(store, vehicleId)
    local new = copyStore(store)
    for _, run in ipairs(new.runs) do
        for index, id in ipairs(run.vehicleIds) do
            if id == vehicleId then
                table.remove(run.vehicleIds, index)
                break
            end
        end
    end
    return new
end

-- En traktor er kun i ét run. Returnerer ny store + id på det run den blev flyttet FRA (nil = ingen).
function ADRuns.assignVehicle(store, runId, vehicleId)
    local previous = ADRuns.findRunByVehicle(store, vehicleId)
    if previous ~= nil and previous.id == runId then
        return copyStore(store), nil
    end
    local new = ADRuns.unassignVehicle(store, vehicleId)
    local run = ADRuns.getRun(new, runId)
    if run == nil then
        return new, nil
    end
    table.insert(run.vehicleIds, vehicleId)
    return new, previous ~= nil and previous.id or nil
end

-- Varer gemmes som mellemrumsadskilte fillType-NAVNE ("STRAW FORAGE") — navne er
-- stabile på tværs af mods/kort, fillType-indeks er det ikke.
function ADRuns.serializeIngredients(list)
    return table.concat(list or {}, " ")
end

function ADRuns.parseIngredients(text)
    local list = {}
    for name in string.gmatch(tostring(text or ""), "%S+") do
        table.insert(list, name)
    end
    return list
end

-- Flade poster (kun strenge/tal) til XML-laget, i store'ens rækkefølge.
function ADRuns.toRecords(store)
    local records = {}
    for index, run in ipairs(store.runs) do
        records[index] = {
            id = run.id,
            name = run.name,
            target = run.targetWayPointId or 0,
            ingredientMode = run.ingredientMode,
            ingredients = ADRuns.serializeIngredients(run.ingredients),
            waitPool = run.waitPoolGroup,
            loops = run.loops,
            vehicleIds = copyList(run.vehicleIds),
        }
    end
    return records
end

-- Poster uden gyldigt id/navn eller med dublet-id springes over; nextId = højeste id + 1.
function ADRuns.fromRecords(records)
    local store = ADRuns.newStore()
    for _, record in ipairs(records or {}) do
        local id = tonumber(record.id)
        local isValid = id ~= nil and id >= 1 and record.name ~= nil and record.name ~= ""
        if isValid and ADRuns.getRun(store, id) == nil then
            local target = tonumber(record.target)
            table.insert(store.runs, {
                id = id,
                name = record.name,
                targetWayPointId = target ~= nil and target > 0 and target or nil,
                ingredientMode = ADRuns.INGREDIENT_MODES[record.ingredientMode] and record.ingredientMode or "auto",
                ingredients = ADRuns.parseIngredients(record.ingredients),
                waitPoolGroup = record.waitPool or "",
                loops = clampLoops(record.loops),
                vehicleIds = copyList(record.vehicleIds),
            })
            store.nextId = math.max(store.nextId, id + 1)
        end
    end
    return store
end
  • Step 4: Kør testen og se den bestå

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adRuns.lua Forventet: ALLE TESTS OK.

  • Step 5: Commit
cd ~/fs25-adsmartpickup && git add FS25_ADSmartPickup/adRuns.lua tests/test_adRuns.lua && git commit -q -m "feat: run-model (rene funktioner) til runs-UI"

Task 2: XML-lagring (adRunsStorage.lua)

Files:

  • Create: FS25_ADSmartPickup/adRunsStorage.lua
  • Modify: tests/test_adRuns.lua (tilføj X1–X3 før print(failures ...))

Interfaces:

  • Consumes: ADRuns.toRecords, ADRuns.fromRecords, ADRuns.XML_VERSION (Task 1)
  • Produces:
    • ADRunsStorage.FILE_NAME = "ADSmartPickup_runs.xml", ADRunsStorage.ROOT = "ADSmartPickupRuns"
    • ADRunsStorage.getPath() -> string|nil
    • ADRunsStorage.writeRecords(xmlFile, records), ADRunsStorage.readRecords(xmlFile) -> records
    • ADRunsStorage.save(store) -> boolean, ADRunsStorage.load() -> store

XML-format (spec): <ADSmartPickupRuns version="1"><run id name target ingredientMode ingredients waitPool loops><vehicle id="…"/></run></ADSmartPickupRuns>.

  • Step 1: Tilføj fejlende tests

Indsæt i tests/test_adRuns.lua lige før print(failures == 0 ...):

-- ==== XML-laget mod en flad XMLFile-mock (nøgle -> værdi) ====
dofile("FS25_ADSmartPickup/adRunsStorage.lua")

local function makeXmlMock(values)
    local xml = {values = values or {}, saved = false, deleted = false}
    xml.setInt = function(self, key, value) self.values[key] = value end
    xml.setString = function(self, key, value) self.values[key] = value end
    xml.getInt = function(self, key) return tonumber(self.values[key]) end
    xml.getString = function(self, key) local v = self.values[key]; return v ~= nil and tostring(v) or nil end
    -- iterate: kalder callback for "prefix(0)", "prefix(1)", ... så længe en nøgle med den stamme findes
    xml.iterate = function(self, prefix, callback)
        local index = 0
        while true do
            local key = string.format("%s(%d)", prefix, index)
            local found = false
            for k in pairs(self.values) do
                if k:sub(1, #key) == key then found = true break end
            end
            if not found then return end
            callback(index + 1, key)
            index = index + 1
        end
    end
    xml.save = function(self) self.saved = true end
    xml.delete = function(self) self.deleted = true end
    return xml
end

-- X1: records skrives som attributter + vehicle-underelementer
local out = makeXmlMock()
ADRunsStorage.writeRecords(out, ADRuns.toRecords(s5))
check("X1 version", out.values["ADSmartPickupRuns#version"], 1)
check("X1 run 0 navn", out.values["ADSmartPickupRuns.run(0)#name"], "Cow 1")
check("X1 run 0 target", out.values["ADSmartPickupRuns.run(0)#target"], 94)
check("X1 run 0 varer", out.values["ADSmartPickupRuns.run(0)#ingredients"], "STRAW FORAGE")
check("X1 run 0 pulje", out.values["ADSmartPickupRuns.run(0)#waitPool"], "Cow Wait")
check("X1 run 1 køretøj", out.values["ADSmartPickupRuns.run(1).vehicle(0)#id"], "vehicleA")

-- X2: samme data læses tilbage
local records2 = ADRunsStorage.readRecords(makeXmlMock(out.values))
check("X2 to poster", #records2, 2)
check("X2 id", records2[1].id, 1)
check("X2 køretøj", records2[2].vehicleIds[1], "vehicleA")
check("X2 loops", records2[1].loops, 99)
check("X2 round-trip", ADRuns.findRunByVehicle(ADRuns.fromRecords(records2), "vehicleA").id, 2)

-- X3: save/load via XMLFile-globalen; ingen savegame-mappe -> tom store, intet skrives
local created, loaded = nil, nil
XMLFile = {
    create = function(_, path, root) created = makeXmlMock(); created.path = path; created.root = root; return created end,
    load = function(_, path) loaded = makeXmlMock(created and created.values or {}); loaded.path = path; return loaded end,
}
fileExists = function(path) return created ~= nil and created.path == path end
g_currentMission = {missionInfo = {savegameDirectory = "/tmp/sg1"}}
check("X3 gemt", ADRunsStorage.save(s5), true)
check("X3 sti", created.path, "/tmp/sg1/ADSmartPickup_runs.xml")
check("X3 rodelement", created.root, "ADSmartPickupRuns")
check("X3 save+delete kaldt", created.saved and created.deleted, true)
check("X3 indlæst", #ADRunsStorage.load().runs, 2)
check("X3 læse-handle lukket", loaded.deleted, true)
g_currentMission = {missionInfo = {}}
check("X3 uden savegame -> false", ADRunsStorage.save(s5), false)
check("X3 uden savegame -> tom store", #ADRunsStorage.load().runs, 0)
g_currentMission = {missionInfo = {savegameDirectory = "/tmp/sg-ny"}}
check("X3 fil mangler -> tom store", #ADRunsStorage.load().runs, 0)
  • Step 2: Kør og se den fejle

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adRuns.lua Forventet: fejler med cannot open FS25_ADSmartPickup/adRunsStorage.lua.

  • Step 3: Skriv lagringen

FS25_ADSmartPickup/adRunsStorage.lua:

-- AD Runs Storage
-- Runs gemmes i savegame-mappen ved siden af AutoDrive_config.xml. Kun dette lag
-- kender XMLFile; modellen (adRuns) ser kun flade poster.

ADRunsStorage = {}

ADRunsStorage.FILE_NAME = "ADSmartPickup_runs.xml"
ADRunsStorage.ROOT = "ADSmartPickupRuns"
ADRunsStorage.LOG_PREFIX = "ADSmartPickup: "

-- Samme opslag som AutoDrive.getXMLFile_new: mappen kendes efter første gem, ellers via indeks.
function ADRunsStorage.getPath()
    local info = g_currentMission ~= nil and g_currentMission.missionInfo or nil
    if info == nil then
        return nil
    end
    if info.savegameDirectory ~= nil then
        return info.savegameDirectory .. "/" .. ADRunsStorage.FILE_NAME
    end
    if info.savegameIndex ~= nil and getUserProfileAppPath ~= nil then
        return getUserProfileAppPath() .. "savegame" .. tostring(info.savegameIndex) .. "/" .. ADRunsStorage.FILE_NAME
    end
    return nil
end

function ADRunsStorage.writeRecords(xmlFile, records)
    xmlFile:setInt(ADRunsStorage.ROOT .. "#version", ADRuns.XML_VERSION)
    for index, record in ipairs(records) do
        local key = string.format("%s.run(%d)", ADRunsStorage.ROOT, index - 1)
        xmlFile:setInt(key .. "#id", record.id)
        xmlFile:setString(key .. "#name", record.name)
        xmlFile:setInt(key .. "#target", record.target)
        xmlFile:setString(key .. "#ingredientMode", record.ingredientMode)
        xmlFile:setString(key .. "#ingredients", record.ingredients)
        xmlFile:setString(key .. "#waitPool", record.waitPool)
        xmlFile:setInt(key .. "#loops", record.loops)
        for vehicleIndex, vehicleId in ipairs(record.vehicleIds) do
            xmlFile:setString(string.format("%s.vehicle(%d)#id", key, vehicleIndex - 1), vehicleId)
        end
    end
end

function ADRunsStorage.readRecords(xmlFile)
    local records = {}
    xmlFile:iterate(ADRunsStorage.ROOT .. ".run", function(_, key)
        local record = {
            id = xmlFile:getInt(key .. "#id"),
            name = xmlFile:getString(key .. "#name"),
            target = xmlFile:getInt(key .. "#target"),
            ingredientMode = xmlFile:getString(key .. "#ingredientMode"),
            ingredients = xmlFile:getString(key .. "#ingredients"),
            waitPool = xmlFile:getString(key .. "#waitPool"),
            loops = xmlFile:getInt(key .. "#loops"),
            vehicleIds = {},
        }
        xmlFile:iterate(key .. ".vehicle", function(_, vehicleKey)
            local id = xmlFile:getString(vehicleKey .. "#id")
            if id ~= nil and id ~= "" then
                table.insert(record.vehicleIds, id)
            end
        end)
        table.insert(records, record)
    end)
    return records
end

function ADRunsStorage.save(store)
    local path = ADRunsStorage.getPath()
    if path == nil then
        return false
    end
    local xmlFile = XMLFile.create("ADSmartPickupRuns", path, ADRunsStorage.ROOT)
    if xmlFile == nil then
        Logging.warning(ADRunsStorage.LOG_PREFIX .. "kunne ikke oprette %s", path)
        return false
    end
    ADRunsStorage.writeRecords(xmlFile, ADRuns.toRecords(store))
    xmlFile:save()
    xmlFile:delete()
    return true
end

function ADRunsStorage.load()
    local path = ADRunsStorage.getPath()
    if path == nil or not fileExists(path) then
        return ADRuns.newStore()
    end
    local xmlFile = XMLFile.load("ADSmartPickupRuns", path)
    if xmlFile == nil then
        Logging.warning(ADRunsStorage.LOG_PREFIX .. "kunne ikke læse %s; starter uden runs", path)
        return ADRuns.newStore()
    end
    local records = ADRunsStorage.readRecords(xmlFile)
    xmlFile:delete()
    return ADRuns.fromRecords(records)
end

Bemærk: testen definerer ikke Logging; save/load når kun Logging.warning når XMLFile.create/load returnerer nil, hvilket mock'en ikke gør. Tilføj øverst i test-blokken X3 alligevel Logging = Logging or {warning = function() end, info = function() end} så en fremtidig test ikke crasher.

  • Step 4: Kør og se den bestå

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adRuns.lua Forventet: ALLE TESTS OK.

  • Step 5: Commit
cd ~/fs25-adsmartpickup && git add FS25_ADSmartPickup/adRunsStorage.lua tests/test_adRuns.lua && git commit -q -m "feat: runs gemmes i savegameN/ADSmartPickup_runs.xml"

Task 3: Eksponér stations-opslag i adSmartPickup.lua

Controlleren (Task 4) og byttet (Task 8) skal bruge de lokale opslag. Ingen adfærdsændring.

Files:

  • Modify: FS25_ADSmartPickup/adSmartPickup.lua:143-149 (load/unload-station), :154-167 (getOwnStorageLevel), :361-380 (getTargetLevelAndCapacity)
  • Test: tests/test_adSmartPickup.lua (tilføj E1–E3 før -- L6:-blokken nederst)

Interfaces:

  • Produces:

    • ADSmartPickup.getLoadStationAtWayPointId(adEnv, wayPointId, farmId) -> station|nil
    • ADSmartPickup.getUnloadStationAtWayPointId(adEnv, wayPointId, farmId) -> station|nil
    • ADSmartPickup.getStationLevelAndCapacity(station, fillType, farmId) -> level, capacity | nil
    • ADSmartPickup.getOwnStorageLevel(station, fillTypes) -> liter
    • ADSmartPickup.getFarmId(vehicle) -> farmId|nil
  • Step 1: Tilføj fejlende tests

Indsæt i tests/test_adSmartPickup.lua lige før linjen -- L6: samme valg logges kun én gang:

-- ==== E: eksponerede opslag (bruges af runs-controller og læs-bytte) ====
local eDriver = buildCowWorld()
check("E1 aflæsningsstation ved waypoint", ADSmartPickup.getUnloadStationAtWayPointId(FS25_AutoDrive, 30, 1), unloadStations[1])
check("E1 ukendt waypoint -> nil", ADSmartPickup.getUnloadStationAtWayPointId(FS25_AutoDrive, 9999, 1), nil)
check("E2 læssestation ved waypoint", ADSmartPickup.getLoadStationAtWayPointId(FS25_AutoDrive, 21, 1), stations[2])
local eLevel, eCapacity = ADSmartPickup.getStationLevelAndCapacity(unloadStations[1], TMR, 1)
check("E3 niveau i målet", eLevel, 40000)
check("E3 kapacitet i målet", eCapacity, 100000)
check("E3 vare målet ikke tager -> nil", ADSmartPickup.getStationLevelAndCapacity(unloadStations[1], MILK, 1), nil)
check("E3 farmId", ADSmartPickup.getFarmId(eDriver), 1)
  • Step 2: Kør og se den fejle

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adSmartPickup.lua 2>&1 | tail -3 Forventet: fejler med attempt to call field 'getUnloadStationAtWayPointId' (a nil value).

  • Step 3: Eksponér funktionerne

I adSmartPickup.lua:

  1. Erstat local function getFarmId(vehicle) med function ADSmartPickup.getFarmId(vehicle) og tilføj lige under funktionen: local getFarmId = ADSmartPickup.getFarmId.
  2. Erstat local function getOwnStorageLevel(station, fillTypes) med function ADSmartPickup.getOwnStorageLevel(station, fillTypes) og tilføj under den: local getOwnStorageLevel = ADSmartPickup.getOwnStorageLevel.
  3. Erstat local function getTargetLevelAndCapacity(station, fillType, farmId) med function ADSmartPickup.getStationLevelAndCapacity(station, fillType, farmId) og tilføj under den: local getTargetLevelAndCapacity = ADSmartPickup.getStationLevelAndCapacity.
  4. Tilføj lige efter getUnloadStationAtMarker:
-- Station ved et AutoDrive-waypoint (bruges af runs-controlleren og læs-byttet); nil uden station.
function ADSmartPickup.getLoadStationAtWayPointId(adEnv, wayPointId, farmId)
    local wayPoint = adEnv.ADGraphManager:getWayPointById(wayPointId)
    return wayPoint ~= nil and getLoadStationAtMarker(adEnv, wayPoint, farmId) or nil
end

function ADSmartPickup.getUnloadStationAtWayPointId(adEnv, wayPointId, farmId)
    local wayPoint = adEnv.ADGraphManager:getWayPointById(wayPointId)
    return wayPoint ~= nil and getUnloadStationAtMarker(adEnv, wayPoint, farmId) or nil
end

Lua 5.1-fælde: local getFarmId = ADSmartPickup.getFarmId skal stå EFTER definitionen, og de funktioner der bruger navnet skal stå efter local-linjen (det gør de allerede, da rækkefølgen bevares).

  • Step 4: Kør alle tests

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adSupplyPlanner.lua | tail -1 && luajit tests/test_adSmartPickup.lua | tail -1 Forventet: ALLE TESTS OK to gange.

  • Step 5: Commit
cd ~/fs25-adsmartpickup && git add -A FS25_ADSmartPickup/adSmartPickup.lua tests/test_adSmartPickup.lua && git commit -q -m "refactor: eksponér stations- og niveau-opslag til runs-controller"

Task 4: Runs-controller (adRunsController.lua)

Files:

  • Create: FS25_ADSmartPickup/adRunsController.lua
  • Test: tests/test_adRunsController.lua

Interfaces:

  • Consumes: ADRuns.* (Task 1), ADRunsStorage.save/load (Task 2), ADSmartPickup.getUnloadStationAtWayPointId/getLoadStationAtWayPointId/getStationLevelAndCapacity/getFarmId (Task 3), ADSupplyPlanner.isWaitMarker.

  • Produces:

    • ADRunsController.store (aktuel store; erstattes ved hver ændring), ADRunsController.MAX_NETWORK_DISTANCE = 30
    • ADRunsController.getVehicleId(vehicle) -> string|nil
    • ADRunsController.getFarmVehicles(adEnv) -> {vehicle, ...} (farmens køretøjer med vehicle.ad)
    • ADRunsController.findVehicleById(adEnv, id) -> vehicle|nil
    • ADRunsController.getRunForVehicle(vehicle) -> run|nil
    • ADRunsController.getCarriableFillTypes(adEnv, vehicle) -> {fillTypeIndex, ...}
    • ADRunsController.getTargetFillTypes(adEnv, run, vehicle) -> {fillTypeIndex, ...} (Auto-listen: det vognen kan bære ∩ det målet tager)
    • ADRunsController.getRunIngredients(adEnv, run, vehicle) -> {fillTypeIndex, ...} (manual: navne → indeks; auto: getTargetFillTypes)
    • ADRunsController.validateVehicle(adEnv, run, vehicle) -> {ok = boolean, reason = "noTrailer"|"cannotCarry"|"farFromNetwork"|"noTarget"|nil, capacity = liter|nil, carriable = list}
    • ADRunsController.startRun(adEnv, runId) -> {{vehicle, ok, reason}, ...}
    • ADRunsController.stopRun(adEnv, runId) -> antal stoppede
    • ADRunsController.getRunStatus(adEnv, run) -> {running = n, total = m}
    • ADRunsController.getTargetMarkers(adEnv, farmId) -> {marker, ...} (markører med aflæsningsstation, sorteret på navn)
    • ADRunsController.getWaitPools(adEnv) -> {{group = "Cow Wait", count = 6}, ...} sorteret på navn
    • ADRunsController.createRun(name) -> run, .deleteRun(runId), .updateRun(runId, changes), .assignVehicle(runId, vehicle) -> previousRunId|nil, .unassignVehicle(vehicle) — wrapper der erstatter ADRunsController.store
    • ADRunsController.load(), ADRunsController.save(), ADRunsController.installSaveHook()
  • Step 1: Skriv de fejlende tests

tests/test_adRunsController.lua:

-- Kør: luajit tests/test_adRunsController.lua   (fra repo-roden)
-- Controlleren oversætter et run til AutoDrive-tilstand pr. traktor. Mock'er AD + de
-- få ADSmartPickup-opslag den bruger.

local STRAW, TMR, WATER, MILK = 31, 116, 8, 30
local FILL_TYPE_NAMES = {[STRAW] = "STRAW", [TMR] = "FORAGE", [WATER] = "WATER", [MILK] = "MILK"}
local FILL_TYPE_INDEX = {}
for index, name in pairs(FILL_TYPE_NAMES) do FILL_TYPE_INDEX[name] = index end
g_fillTypeManager = {
    getFillTypeNameByIndex = function(_, index) return FILL_TYPE_NAMES[index] end,
    getFillTypeIndexByName = function(_, name) return FILL_TYPE_INDEX[name] end,
}
local logLines = {}
Logging = {
    info = function(fmt, ...) table.insert(logLines, string.format(fmt, ...)) end,
    warning = function(fmt, ...) table.insert(logLines, "WARN " .. string.format(fmt, ...)) end,
}
Utils = {prependedFunction = function(original, extra) return function(...) extra(...); return original(...) end end}
ItemSystem = {save = function() end}
g_currentMission = {getFarmId = function() return 1 end, missionInfo = {}}

-- ---- AutoDrive-mock ----
local RT_NONE, RT_ONLYPICKUP = 1, 2
local MODE_PICKUPANDDELIVER = 2
local vehicles, markers, wayPoints = {}, {}, {}
FS25_AutoDrive = {
    AutoDrive = {
        RT_NONE = RT_NONE, RT_ONLYPICKUP = RT_ONLYPICKUP, MODE_PICKUPANDDELIVER = MODE_PICKUPANDDELIVER,
        settings = {
            useFolders = {values = {false, true}, current = 1, isVehicleSpecific = false},
            rotateTargets = {values = {1, 2, 3, 4}, current = 1, isVehicleSpecific = true},
        },
        getAllVehicles = function() return vehicles end,
        getAllUnits = function(vehicle) return vehicle.trailers end,
        getAllFillLevels = function(trailers) return trailers.level or 0, trailers.capacity or 0, false, 0 end,
        getSupportedFillTypesOfAllUnitsAlphabetically = function(vehicle) return vehicle.carriable or {} end,
    },
    ADGraphManager = {
        getMapMarkers = function() return markers end,
        getMapMarkerByWayPointId = function(_, id) for _, m in ipairs(markers) do if m.id == id then return m end end end,
        getWayPointById = function(_, id) return wayPoints[id] end,
        getDistanceFromNetwork = function(_, vehicle) return vehicle.networkDistance or 0 end,
    },
}

local function addMarker(id, name, group, x, z)
    wayPoints[id] = {x = x, z = z, incoming = {}}
    table.insert(markers, {id = id, markerIndex = #markers + 1, name = name, group = group})
end

-- ADSmartPickup-opslag: hvilke waypoints har hvilken slags station, og hvad målet tager
local unloadAt, loadAt = {}, {}
ADSmartPickup = {
    getFarmId = function(vehicle) return vehicle.farmId end,
    getUnloadStationAtWayPointId = function(_, id) return unloadAt[id] end,
    getLoadStationAtWayPointId = function(_, id) return loadAt[id] end,
    getStationLevelAndCapacity = function(station, fillType)
        if station.accepts[fillType] == nil then return nil end
        return 0, station.accepts[fillType]
    end,
}
ADSupplyPlanner = {isWaitMarker = function(marker) return string.find(string.lower(marker.group or ""), "%f[%a]wait%f[%A]") ~= nil end}

local function makeVehicle(uniqueId, name, carriable, capacity, x, z)
    local stateModule = {selectedFillTypes = {}, fillType = 0, active = false, loopCounter = 5, loopsDone = 3, dirtyCount = 0}
    stateModule.setMode = function(self, mode) self.mode = mode end
    stateModule.setFirstMarkerByWayPointId = function(self, id) self.firstMarkerId = id end
    stateModule.setSecondMarkerByWayPointId = function(self, id) self.secondMarkerId = id end
    stateModule.setFillType = function(self, fillType) self.fillType = fillType end
    stateModule.raiseDirtyFlag = function(self) self.dirtyCount = self.dirtyCount + 1 end
    stateModule.setLoopsDone = function(self, n) self.loopsDone = n end
    stateModule.isActive = function(self) return self.active end
    local vehicle = {
        uniqueId = uniqueId, farmId = 1, rootNode = uniqueId,
        carriable = carriable, trailers = {level = 0, capacity = capacity},
        getName = function() return name end,
        getOwnerFarmId = function() return 1 end,
        startAutoDrive = function(self) self.ad.stateModule.active = true; self.started = (self.started or 0) + 1 end,
        stopAutoDrive = function(self) self.ad.stateModule.active = false; self.stopped = (self.stopped or 0) + 1 end,
        ad = {stateModule = stateModule, settings = {rotateTargets = {values = {1, 2, 3, 4}, current = 1, new = 1, isVehicleSpecific = true}}},
    }
    nodePositions = nodePositions or {}
    nodePositions[uniqueId] = {x = x or 0, z = z or 0}
    table.insert(vehicles, vehicle)
    return vehicle
end
function getWorldTranslation(node) local p = nodePositions[node]; return p.x, 0, p.z end

dofile("FS25_ADSmartPickup/adRuns.lua")
dofile("FS25_ADSmartPickup/adRunsStorage.lua")
dofile("FS25_ADSmartPickup/adRunsController.lua")
local adEnv = FS25_AutoDrive

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 buildWorld()
    vehicles, markers, wayPoints, unloadAt, loadAt, logLines = {}, {}, {}, {}, {}, {}
    ADRunsController.store = ADRuns.newStore()
    addMarker(94, "Cow 1 Food", "Cow Food", 5000, 0);  unloadAt[94] = {accepts = {[STRAW] = 300000, [TMR] = 300000, [WATER] = 50000}}
    addMarker(44, "10 Silo Storage", "Siloer", 0, 0);  loadAt[44] = {}
    addMarker(75, "TMR Load", "Fabrication", 900, 0);  loadAt[75] = {}
    addMarker(100, "Cow Wait", "Cow Wait", 5100, 0)
    addMarker(101, "Cow Wait", "Cow Wait", 5200, 0)
    addMarker(200, "F91 Wait", "F91", 8000, 0)
    addMarker(300, "Pig Wait", "Pig Wait", 9000, 0)
end

-- C1: køretøjs-id og farmens AD-køretøjer
buildWorld()
local fastracA = makeVehicle("vehicleA", "Fastrac A", {STRAW, TMR}, 1000000, 100, 0)
local fastracB = makeVehicle("vehicleB", "Fastrac B", {STRAW}, 700000, 4000, 0)
local noAd = {uniqueId = "vehicleC", getOwnerFarmId = function() return 1 end}
table.insert(vehicles, noAd)
local otherFarm = makeVehicle("vehicleD", "Nabo", {STRAW}, 1000, 0, 0); otherFarm.getOwnerFarmId = function() return 2 end
check("C1 uniqueId", ADRunsController.getVehicleId(fastracA), "vehicleA")
check("C1 fallback savegame-id", ADRunsController.getVehicleId({currentSavegameId = 7}), "7")
check("C1 intet id -> nil", ADRunsController.getVehicleId({}), nil)
check("C1 farmens AD-køretøjer", #ADRunsController.getFarmVehicles(adEnv), 2)
check("C1 opslag på id", ADRunsController.findVehicleById(adEnv, "vehicleB"), fastracB)
check("C1 ukendt id -> nil", ADRunsController.findVehicleById(adEnv, "nope"), nil)

-- C2: opret run, tildel; opslag run for køretøj; flyt mellem runs
local cow = ADRunsController.createRun("Cow Feed")
check("C2 run oprettet", ADRuns.getRun(ADRunsController.store, cow.id).name, "Cow Feed")
check("C2 ingen tidligere", ADRunsController.assignVehicle(cow.id, fastracA), nil)
check("C2 run for køretøj", ADRunsController.getRunForVehicle(fastracA).id, cow.id)
check("C2 ikke tildelt -> nil", ADRunsController.getRunForVehicle(fastracB), nil)
local pig = ADRunsController.createRun("Pig Feed")
check("C2 flyttet fra Cow", ADRunsController.assignVehicle(pig.id, fastracA), cow.id)
check("C2 nu i Pig", ADRunsController.getRunForVehicle(fastracA).id, pig.id)
ADRunsController.unassignVehicle(fastracA)
check("C2 fjernet", ADRunsController.getRunForVehicle(fastracA), nil)
ADRunsController.deleteRun(pig.id)
check("C2 slettet", ADRuns.getRun(ADRunsController.store, pig.id), nil)

-- C3: Auto-vareliste = vognens varer ∩ målets varer; manual = navne -> indeks (ukendte droppes)
ADRunsController.updateRun(cow.id, {targetWayPointId = 94})
local cowRun = ADRuns.getRun(ADRunsController.store, cow.id)
check("C3 auto: halm+TMR (vand kan vognen ikke bære)", table.concat(ADRunsController.getRunIngredients(adEnv, cowRun, fastracA), ","), STRAW .. "," .. TMR)
check("C3 auto: kun halm for halm-vogn", table.concat(ADRunsController.getRunIngredients(adEnv, cowRun, fastracB), ","), tostring(STRAW))
ADRunsController.updateRun(cow.id, {ingredientMode = "manual", ingredients = {"FORAGE", "UNKNOWN_THING"}})
cowRun = ADRuns.getRun(ADRunsController.store, cow.id)
check("C3 manual: FORAGE -> indeks, ukendt droppes", table.concat(ADRunsController.getRunIngredients(adEnv, cowRun, fastracA), ","), tostring(TMR))
ADRunsController.updateRun(cow.id, {ingredientMode = "auto"})
local noTarget = ADRunsController.createRun("Uden mål")
check("C3 uden mål -> tom liste", #ADRunsController.getRunIngredients(adEnv, ADRuns.getRun(ADRunsController.store, noTarget.id), fastracA), 0)

-- C4: validering
cowRun = ADRuns.getRun(ADRunsController.store, cow.id)
check("C4 ok", ADRunsController.validateVehicle(adEnv, cowRun, fastracA).ok, true)
check("C4 kapacitet", ADRunsController.validateVehicle(adEnv, cowRun, fastracA).capacity, 1000000)
local noTrailer = makeVehicle("vehicleE", "Uden vogn", {}, 0)
check("C4 ingen vogn", ADRunsController.validateVehicle(adEnv, cowRun, noTrailer).reason, "noTrailer")
local milkTruck = makeVehicle("vehicleF", "Mælkebil", {MILK}, 20000)
check("C4 kan ikke bære", ADRunsController.validateVehicle(adEnv, cowRun, milkTruck).reason, "cannotCarry")
fastracB.networkDistance = 31
check("C4 for langt fra vejnettet", ADRunsController.validateVehicle(adEnv, cowRun, fastracB).reason, "farFromNetwork")
fastracB.networkDistance = 0
check("C4 intet mål", ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, noTarget.id), fastracA).reason, "noTarget")
ADRunsController.updateRun(noTarget.id, {targetWayPointId = 4242})
check("C4 mål-markør findes ikke", ADRunsController.validateVehicle(adEnv, ADRuns.getRun(ADRunsController.store, noTarget.id), fastracA).reason, "noTarget")

-- C5: Start skriver AD-tilstand og starter; ✖-traktorer springes over
ADRunsController.updateRun(cow.id, {loops = 4, waitPoolGroup = "Cow Wait"})
ADRunsController.assignVehicle(cow.id, fastracA)
ADRunsController.assignVehicle(cow.id, noTrailer)
local results = ADRunsController.startRun(adEnv, cow.id)
check("C5 to resultater", #results, 2)
check("C5 A startet", fastracA.started, 1)
check("C5 A ok", results[1].ok, true)
check("C5 uden vogn sprunget over", noTrailer.started, nil)
check("C5 uden vogn grund", results[2].reason, "noTrailer")
local state = fastracA.ad.stateModule
check("C5 mode P&D", state.mode, MODE_PICKUPANDDELIVER)
check("C5 mål = run'ets mål", state.secondMarkerId, 94)
check("C5 start-pickup = nærmeste læssestation", state.firstMarkerId, 44)
check("C5 varer = auto-listen", table.concat(state.selectedFillTypes, ","), STRAW .. "," .. TMR)
check("C5 fillType = første", state.fillType, STRAW)
check("C5 loops", state.loopCounter, 4)
check("C5 loopsDone nulstillet", state.loopsDone, 0)
check("C5 rotateTargets = kun pålæsning (current)", fastracA.ad.settings.rotateTargets.current, 2)
check("C5 rotateTargets = kun pålæsning (new)", fastracA.ad.settings.rotateTargets.new, 2)
check("C5 useFolders globalt til", adEnv.AutoDrive.settings.useFolders.current, 2)
check("C5 dirty flag", state.dirtyCount > 0, true)
check("C5 status", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).running, 1)
check("C5 total", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).total, 2)
check("C5 ukendt run -> tom", #ADRunsController.startRun(adEnv, 999), 0)

-- C6: Stop
check("C6 stoppede", ADRunsController.stopRun(adEnv, cow.id), 1)
check("C6 A stoppet", fastracA.stopped, 1)
check("C6 status 0", ADRunsController.getRunStatus(adEnv, ADRuns.getRun(ADRunsController.store, cow.id)).running, 0)
check("C6 medlemskab bevaret", ADRunsController.getRunForVehicle(fastracA).id, cow.id)

-- C7: Start på traktor der allerede kører -> ikke startet igen, men tilstand skrives
fastracA.ad.stateModule.active = true
ADRunsController.startRun(adEnv, cow.id)
check("C7 ikke dobbelt-startet", fastracA.started, 1)

-- C8: mål-markører og Wait-puljer til dropdowns
local targets = ADRunsController.getTargetMarkers(adEnv, 1)
check("C8 kun markører med aflæsningsstation", #targets, 1)
check("C8 navn", targets[1].name, "Cow 1 Food")
local pools = ADRunsController.getWaitPools(adEnv)
check("C8 to puljer (F91 er ikke en Wait-mappe)", #pools, 2)
check("C8 sorteret", pools[1].group, "Cow Wait")
check("C8 antal", pools[1].count, 2)

-- C9: gem/indlæs; ukendte køretøjs-id'er logges ved indlæsning men beholdes i run'et
local savedRecords
ADRunsStorage.save = function(store) savedRecords = ADRuns.toRecords(store); return true end
ADRunsStorage.load = function() return ADRuns.fromRecords(savedRecords) end
ADRunsController.assignVehicle(cow.id, {uniqueId = "vehicleGONE", ad = {}, getOwnerFarmId = function() return 1 end, getName = function() return "Væk" end})
check("C9 gemt", ADRunsController.save(), true)
ADRunsController.store = ADRuns.newStore()
ADRunsController.load(adEnv)
check("C9 indlæst", ADRuns.getRun(ADRunsController.store, cow.id).name, "Cow Feed")
check("C9 ukendt køretøj logget", logLines[#logLines]:find("vehicleGONE", 1, true) ~= nil, true)
check("C9 run beholder kendte", ADRunsController.getRunForVehicle(fastracA).id, cow.id)
local hooked = false
ItemSystem.save = function() hooked = true end
ADRunsController.installSaveHook()
ItemSystem.save()
check("C9 save-hook kaldes før ItemSystem.save", hooked, true)

print(failures == 0 and "\nALLE TESTS OK" or ("\n" .. failures .. " FEJL"))
os.exit(failures == 0 and 0 or 1)
  • Step 2: Kør og se den fejle

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adRunsController.lua Forventet: cannot open FS25_ADSmartPickup/adRunsController.lua.

  • Step 3: Skriv controlleren

FS25_ADSmartPickup/adRunsController.lua:

-- AD Runs Controller
-- Oversætter et run til AutoDrive-tilstand pr. traktor: validering, Start/Stop, status,
-- Auto-vareliste, og "hvilket run hører traktoren til". GUI'et kalder kun herind;
-- de eksisterende hooks (choosePickup, Wait-punkter) spørger getRunForVehicle/getRunIngredients.

ADRunsController = {}

ADRunsController.LOG_PREFIX = "ADSmartPickup: "
-- Længere væk fra vejnettet end dette kan AutoDrive ikke starte (samme grænse som AD's HUD).
ADRunsController.MAX_NETWORK_DISTANCE = 30

ADRunsController.store = ADRuns.newStore()

local function copyList(list)
    local copy = {}
    for index, value in ipairs(list or {}) do
        copy[index] = value
    end
    return copy
end

-- Stabil nøgle: FS25's uniqueId ("vehicle…"), ellers savegame-id. Navne bruges aldrig.
function ADRunsController.getVehicleId(vehicle)
    if vehicle.uniqueId ~= nil then
        return tostring(vehicle.uniqueId)
    end
    if vehicle.getUniqueId ~= nil then
        local id = vehicle:getUniqueId()
        if id ~= nil then
            return tostring(id)
        end
    end
    if vehicle.currentSavegameId ~= nil then
        return tostring(vehicle.currentSavegameId)
    end
    return nil
end

function ADRunsController.getFarmVehicles(adEnv)
    local farmId = g_currentMission:getFarmId()
    local result = {}
    for _, vehicle in pairs(adEnv.AutoDrive.getAllVehicles()) do
        if vehicle.ad ~= nil and vehicle.getOwnerFarmId ~= nil and vehicle:getOwnerFarmId() == farmId then
            table.insert(result, vehicle)
        end
    end
    return result
end

function ADRunsController.findVehicleById(adEnv, vehicleId)
    for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
        if ADRunsController.getVehicleId(vehicle) == vehicleId then
            return vehicle
        end
    end
    return nil
end

function ADRunsController.getRunForVehicle(vehicle)
    local id = ADRunsController.getVehicleId(vehicle)
    return id ~= nil and ADRuns.findRunByVehicle(ADRunsController.store, id) or nil
end

-- ---- store-wrappers (erstatter altid hele store'en; modellen muterer aldrig) ----

function ADRunsController.createRun(name)
    local store, run = ADRuns.createRun(ADRunsController.store, name)
    ADRunsController.store = store
    return run
end

function ADRunsController.deleteRun(runId)
    ADRunsController.store = ADRuns.deleteRun(ADRunsController.store, runId)
end

function ADRunsController.updateRun(runId, changes)
    ADRunsController.store = ADRuns.updateRun(ADRunsController.store, runId, changes)
end

function ADRunsController.assignVehicle(runId, vehicle)
    local id = ADRunsController.getVehicleId(vehicle)
    if id == nil then
        return nil
    end
    local store, previousRunId = ADRuns.assignVehicle(ADRunsController.store, runId, id)
    ADRunsController.store = store
    return previousRunId
end

function ADRunsController.unassignVehicle(vehicle)
    local id = ADRunsController.getVehicleId(vehicle)
    if id ~= nil then
        ADRunsController.store = ADRuns.unassignVehicle(ADRunsController.store, id)
    end
end

-- ---- varer ----

function ADRunsController.getCarriableFillTypes(adEnv, vehicle)
    return adEnv.AutoDrive.getSupportedFillTypesOfAllUnitsAlphabetically(vehicle) or {}
end

-- Auto-listen: det vognen kan bære, som målet også tager. Er målet en fabrik, indsnævrer
-- findSupplyPickup selv til de ønskede produktioners inputs bagefter.
function ADRunsController.getTargetFillTypes(adEnv, run, vehicle)
    local farmId = ADSmartPickup.getFarmId(vehicle)
    local target = run.targetWayPointId ~= nil and ADSmartPickup.getUnloadStationAtWayPointId(adEnv, run.targetWayPointId, farmId) or nil
    if target == nil then
        return {}
    end
    local result = {}
    for _, fillType in ipairs(ADRunsController.getCarriableFillTypes(adEnv, vehicle)) do
        if ADSmartPickup.getStationLevelAndCapacity(target, fillType, farmId) ~= nil then
            table.insert(result, fillType)
        end
    end
    table.sort(result)
    return result
end

function ADRunsController.getRunIngredients(adEnv, run, vehicle)
    if run.ingredientMode == "manual" then
        local result = {}
        for _, name in ipairs(run.ingredients) do
            local index = g_fillTypeManager:getFillTypeIndexByName(name)
            if index ~= nil then
                table.insert(result, index)
            end
        end
        return result
    end
    return ADRunsController.getTargetFillTypes(adEnv, run, vehicle)
end

-- ---- validering ----

local function getLoadCapacity(adEnv, vehicle)
    local trailers = adEnv.AutoDrive.getAllUnits(vehicle)
    if trailers == nil then
        return nil
    end
    local _, capacity = adEnv.AutoDrive.getAllFillLevels(trailers)
    return capacity ~= nil and capacity > 0 and capacity or nil
end

local function intersects(listA, listB)
    for _, a in ipairs(listA) do
        for _, b in ipairs(listB) do
            if a == b then
                return true
            end
        end
    end
    return false
end

-- reason: "noTarget" | "noTrailer" | "cannotCarry" | "farFromNetwork" (l10n-nøgler i GUI'et).
function ADRunsController.validateVehicle(adEnv, run, vehicle)
    local carriable = ADRunsController.getCarriableFillTypes(adEnv, vehicle)
    local result = {ok = false, carriable = carriable, capacity = getLoadCapacity(adEnv, vehicle)}
    if run.targetWayPointId == nil or adEnv.ADGraphManager:getMapMarkerByWayPointId(run.targetWayPointId) == nil then
        result.reason = "noTarget"
        return result
    end
    if result.capacity == nil then
        result.reason = "noTrailer"
        return result
    end
    if not intersects(ADRunsController.getRunIngredients(adEnv, run, vehicle), carriable) then
        result.reason = "cannotCarry"
        return result
    end
    local distance = adEnv.ADGraphManager.getDistanceFromNetwork ~= nil and adEnv.ADGraphManager:getDistanceFromNetwork(vehicle) or 0
    if distance == nil or distance > ADRunsController.MAX_NETWORK_DISTANCE then
        result.reason = "farFromNetwork"
        return result
    end
    result.ok = true
    return result
end

-- ---- Start / Stop ----

-- AD gemmer indstillinger som INDEKS i values; useFolders er global, rotateTargets pr. køretøj.
local function setSetting(adEnv, vehicle, settingName, value)
    local global = adEnv.AutoDrive.settings[settingName]
    if global == nil then
        return
    end
    local setting = global
    if global.isVehicleSpecific and vehicle.ad.settings ~= nil and vehicle.ad.settings[settingName] ~= nil then
        setting = vehicle.ad.settings[settingName]
    end
    for index, candidate in ipairs(setting.values) do
        if candidate == value then
            setting.current = index
            setting.new = index
            return
        end
    end
end

-- Nærmeste markør med en læssestation: kun en gyldig start — moden vælger den reelle kilde pr. tur.
local function findNearestLoadMarker(adEnv, vehicle, farmId)
    local x, _, z = getWorldTranslation(vehicle.rootNode)
    local best, bestDistance = nil, math.huge
    for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
        if ADSmartPickup.getLoadStationAtWayPointId(adEnv, marker.id, farmId) ~= nil then
            local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id)
            local dx, dz = wayPoint.x - x, wayPoint.z - z
            local distance = dx * dx + dz * dz
            if distance < bestDistance then
                best, bestDistance = marker, distance
            end
        end
    end
    return best
end

local function applyRunToVehicle(adEnv, run, vehicle, ingredients)
    local stateModule = vehicle.ad.stateModule
    local farmId = ADSmartPickup.getFarmId(vehicle)
    stateModule:setMode(adEnv.AutoDrive.MODE_PICKUPANDDELIVER)
    stateModule:setSecondMarkerByWayPointId(run.targetWayPointId)
    local loadMarker = findNearestLoadMarker(adEnv, vehicle, farmId)
    if loadMarker ~= nil then
        stateModule:setFirstMarkerByWayPointId(loadMarker.id)
    end
    stateModule.selectedFillTypes = copyList(ingredients)
    stateModule:setFillType(ingredients[1])
    stateModule.loopCounter = run.loops
    stateModule:setLoopsDone(0)
    setSetting(adEnv, vehicle, "useFolders", true)
    setSetting(adEnv, vehicle, "rotateTargets", adEnv.AutoDrive.RT_ONLYPICKUP)
    stateModule:raiseDirtyFlag()
end

local function isVehicleActive(vehicle)
    local stateModule = vehicle.ad ~= nil and vehicle.ad.stateModule or nil
    return stateModule ~= nil and stateModule.isActive ~= nil and stateModule:isActive()
end

-- Pr. tildelt traktor: validér, skriv AD-tilstand, start. ✖-traktorer springes over.
function ADRunsController.startRun(adEnv, runId)
    local run = ADRuns.getRun(ADRunsController.store, runId)
    local results = {}
    if run == nil then
        return results
    end
    for _, vehicleId in ipairs(run.vehicleIds) do
        local vehicle = ADRunsController.findVehicleById(adEnv, vehicleId)
        if vehicle ~= nil then
            local validation = ADRunsController.validateVehicle(adEnv, run, vehicle)
            local result = {vehicle = vehicle, ok = validation.ok, reason = validation.reason}
            if validation.ok then
                local ingredients = ADRunsController.getRunIngredients(adEnv, run, vehicle)
                local ok, err = pcall(applyRunToVehicle, adEnv, run, vehicle, ingredients)
                if ok then
                    if not isVehicleActive(vehicle) then
                        vehicle:startAutoDrive()
                    end
                    Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': '%s' startet mod markør %s", tostring(run.name), tostring(vehicle:getName()), tostring(run.targetWayPointId))
                else
                    result.ok, result.reason = false, "error"
                    Logging.warning(ADRunsController.LOG_PREFIX .. "run '%s': kunne ikke starte '%s': %s", tostring(run.name), tostring(vehicle:getName()), tostring(err))
                end
            end
            table.insert(results, result)
        end
    end
    return results
end

function ADRunsController.stopRun(adEnv, runId)
    local run = ADRuns.getRun(ADRunsController.store, runId)
    local stopped = 0
    if run == nil then
        return stopped
    end
    for _, vehicleId in ipairs(run.vehicleIds) do
        local vehicle = ADRunsController.findVehicleById(adEnv, vehicleId)
        if vehicle ~= nil and isVehicleActive(vehicle) then
            vehicle:stopAutoDrive()
            stopped = stopped + 1
        end
    end
    return stopped
end

function ADRunsController.getRunStatus(adEnv, run)
    local running = 0
    for _, vehicleId in ipairs(run.vehicleIds) do
        local vehicle = ADRunsController.findVehicleById(adEnv, vehicleId)
        if vehicle ~= nil and isVehicleActive(vehicle) then
            running = running + 1
        end
    end
    return {running = running, total = #run.vehicleIds}
end

-- ---- opslag til dropdowns ----

function ADRunsController.getTargetMarkers(adEnv, farmId)
    local result = {}
    for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
        if ADSmartPickup.getUnloadStationAtWayPointId(adEnv, marker.id, farmId) ~= nil then
            table.insert(result, marker)
        end
    end
    table.sort(result, function(a, b) return tostring(a.name) < tostring(b.name) end)
    return result
end

function ADRunsController.getWaitPools(adEnv)
    local counts = {}
    for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
        if ADSupplyPlanner.isWaitMarker(marker) then
            counts[marker.group] = (counts[marker.group] or 0) + 1
        end
    end
    local result = {}
    for group, count in pairs(counts) do
        table.insert(result, {group = group, count = count})
    end
    table.sort(result, function(a, b) return a.group < b.group end)
    return result
end

-- ---- gem / indlæs ----

function ADRunsController.save()
    local ok, saved = pcall(ADRunsStorage.save, ADRunsController.store)
    if not ok then
        Logging.warning(ADRunsController.LOG_PREFIX .. "kunne ikke gemme runs: %s", tostring(saved))
        return false
    end
    return saved
end

-- Køretøjs-referencer der ikke findes (solgt, andet savegame) logges; run'et beholdes.
function ADRunsController.load(adEnv)
    local ok, store = pcall(ADRunsStorage.load)
    if not ok then
        Logging.warning(ADRunsController.LOG_PREFIX .. "kunne ikke indlæse runs: %s", tostring(store))
        return
    end
    ADRunsController.store = store
    for _, run in ipairs(store.runs) do
        for _, vehicleId in ipairs(run.vehicleIds) do
            if ADRunsController.findVehicleById(adEnv, vehicleId) == nil then
                Logging.info(ADRunsController.LOG_PREFIX .. "run '%s': køretøj %s findes ikke (endnu) i dette savegame", tostring(run.name), tostring(vehicleId))
            end
        end
    end
end

function ADRunsController.installSaveHook()
    if ItemSystem == nil or ItemSystem.save == nil or Utils == nil then
        return
    end
    ItemSystem.save = Utils.prependedFunction(ItemSystem.save, function()
        ADRunsController.save()
    end)
end

Bemærk: køretøjer indlæses i FS25 EFTER loadMap, så "findes ikke"-loglinjen kan komme for køretøjer der dukker op lidt senere — deraf "(endnu)". Opslag i GUI'et sker altid live via id, så det er kun en loglinje.

  • Step 4: Kør og se den bestå

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adRunsController.lua Forventet: ALLE TESTS OK. Fejler C5 "useFolders globalt til" med 1: tjek at setSetting bruger den globale tabel når isVehicleSpecific er false.

  • Step 5: Commit
cd ~/fs25-adsmartpickup && git add FS25_ADSmartPickup/adRunsController.lua tests/test_adRunsController.lua && git commit -q -m "feat: runs-controller (validering, Start/Stop, Auto-vareliste, gem/indlæs)"

Task 5: Integration i choosePickup og Wait-puljen

Files:

  • Modify: FS25_ADSmartPickup/adSmartPickup.lua:478-493 (choosePickup), :647-665 (update, + ny loadMap)
  • Modify: FS25_ADSmartPickup/adUnloadWait.lua:42-53 (getWaitCandidates), :109-113 (kaldet i startWaiting)
  • Modify: FS25_ADSmartPickup/modDesc.xml (extraSourceFiles)
  • Test: tests/test_adSmartPickup.lua (I1–I4)

Interfaces:

  • Consumes: ADRunsController.getRunForVehicle(vehicle), ADRunsController.getRunIngredients(adEnv, run, vehicle), ADRunsController.load(adEnv), ADRunsController.installSaveHook().

  • Produces: ADSmartPickup.getIngredientsFor(adEnv, driver) -> list|nil (run-liste, ellers supplyState-listen; bruges af v1.9).

  • Step 1: Tilføj fejlende tests

I tests/test_adSmartPickup.lua: tilføj dofile("FS25_ADSmartPickup/adRuns.lua") og dofile("FS25_ADSmartPickup/adRunsController.lua") lige før dofile("FS25_ADSmartPickup/adUnloadWait.lua"). Udvid g_fillTypeManager-mock'en (linje 8) med getFillTypeIndexByName = function(_, name) for index, candidate in pairs(FILL_TYPE_NAMES) do if candidate == name then return index end end return nil end (run'ets manuelle varer er navne). Udvid AutoDrive-mock'en (FS25_AutoDrive.AutoDrive) med getSupportedFillTypesOfAllUnitsAlphabetically = function(vehicle) return vehicle.carriable or {} end og getAllVehicles = function() return testVehicles end; tilføj local testVehicles = {} over FS25_AutoDrive = {, og tilføj g_currentMission.getFarmId = function() return 1 end. getSupportedFillTypesOfAllUnitsAlphabetically findes måske allerede i mock'en (fabrikstestene bruger driver.carriable) — behold én definition.

Indsæt før -- ==== E: eksponerede opslag:

-- ==== I: run-integration — ingredienser fra run i stedet for multi-valg; Wait-pulje pr. run ====
-- I1: traktor i et run med ÉN valgt vare i AD får alligevel behovsstyring (run'ets liste)
local runDriver = buildCowWorld()
runDriver.uniqueId = "vehicleRun"
runDriver.ad.stateModule.selectedFillTypes = {STRAW}
runDriver.ad.stateModule.fillType = STRAW
testVehicles = {runDriver}
ADRunsController.store = ADRuns.newStore()
local feedRun = ADRunsController.createRun("Cow Feed")
ADRunsController.updateRun(feedRun.id, {targetWayPointId = 30, ingredientMode = "manual", ingredients = {"FORAGE", "STRAW"}})
ADRunsController.assignVehicle(feedRun.id, runDriver)
check("I1 run-liste styrer: halm mangler -> halmladen", manager:getNextPickup(runDriver, false), 21)
cowLevels[STRAW] = 99500
check("I1 run-liste styrer: TMR når halm er fuld", manager:getNextPickup(runDriver, false), 20)
check("I1 getIngredientsFor = run'ets liste", table.concat(ADSmartPickup.getIngredientsFor(FS25_AutoDrive, runDriver), ","), TMR .. "," .. STRAW)

-- I2: samme traktor uden run -> som før (én vare = mappe-logik i mappen "Lader")
ADRunsController.store = ADRuns.newStore()
runDriver.ad.stateModule.selectedFillTypes = {STRAW}
cowLevels[STRAW] = 0
check("I2 uden run: mappe-logik", manager:getNextPickup(runDriver, false), 21)
check("I2 uden run og én vare: ingen ingrediensliste", ADSmartPickup.getIngredientsFor(FS25_AutoDrive, runDriver), nil)

-- I3: run med tom auto-liste (intet mål) -> falder tilbage til AD's valg
local emptyRun = ADRunsController.createRun("Tomt")
ADRunsController.assignVehicle(emptyRun.id, runDriver)
check("I3 tom run-liste -> som uden run", manager:getNextPickup(runDriver, false), 21)

-- I4: Wait-kandidater begrænses til run'ets pulje
buildWaitWorld()
addMarker(42, "Pig Wait 1", "Pig Wait", 5050, 0) -- nærmere end Cow Wait, men forkert pulje
local vp, tp = makeUnloadingVehicle("Fastrac Pulje", 511, 5000, 35000)
vp.uniqueId = "vehiclePool"
testVehicles = {vp}
ADRunsController.store = ADRuns.newStore()
local poolRun = ADRunsController.createRun("Cow Feed")
ADRunsController.updateRun(poolRun.id, {targetWayPointId = 30, waitPoolGroup = "Cow Wait"})
ADRunsController.assignVehicle(poolRun.id, vp)
UnloadTask.update(tp, 16)
check("I4 kun run'ets pulje", tp.destinationID, 40)
ADRunsController.store = ADRuns.newStore()
  • Step 2: Kør og se den fejle

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adSmartPickup.lua 2>&1 | grep -E "FAIL|FEJL|error" | head Forventet: I1 fejler ("forventede 21, fik ORIGINAL" eller getIngredientsFor nil-kald), I4 fejler med 42.

  • Step 3: Implementér

adSmartPickup.lua — erstat ADSmartPickup.choosePickup:

-- Ingredienslisten for køretøjet: run'ets liste når det er i et run (ingen tændknap
-- nødvendig), ellers AD's multi-valg (>1 vare). nil = ingen behovsstyring.
function ADSmartPickup.getIngredientsFor(adEnv, driver)
    local run = ADRunsController ~= nil and ADRunsController.getRunForVehicle(driver) or nil
    if run ~= nil then
        local ingredients = ADRunsController.getRunIngredients(adEnv, run, driver)
        if #ingredients > 0 then
            return ingredients
        end
        return nil
    end
    return ADSupplyPlanner.resolveIngredients(supplyState[driver], driver.ad.stateModule:getSelectedFillTypes())
end

-- Samlet valg: behovsstyret med ingrediensliste, ellers mappe-logik. nil = AutoDrives original.
function ADSmartPickup.choosePickup(adEnv, driver)
    local ingredients = ADSmartPickup.getIngredientsFor(adEnv, driver)
    if ingredients == nil then
        supplyState[driver] = nil
        return ADSmartPickup.findLeastFilledPickup(adEnv, driver)
    end

    local choice = ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients)
    if choice == nil then
        return ADSmartPickup.findLeastFilledPickup(adEnv, driver)
    end
    applySupplyChoice(driver, ingredients, choice)
    return choice
end

Tilføj under installHook-kaldet i ADSmartPickup:update (inde i if adEnv ~= nil and ...-blokken, efter isHooked = true):

            if ADRunsController ~= nil then
                pcall(ADRunsController.load, adEnv)
                pcall(ADRunsController.installSaveHook)
            end

(Runs indlæses her og ikke i loadMap, fordi AD's globals først er sikre når hook'en installeres, og køretøjerne er indlæst på det tidspunkt.)

adUnloadWait.lua — erstat getWaitCandidates:

-- poolGroup: run'ets Wait-mappe; tom/nil = alle Wait-mapper.
local function getWaitCandidates(adEnv, poolGroup)
    local candidates = {}
    for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
        local isInPool = poolGroup == nil or poolGroup == "" or marker.group == poolGroup
        if isInPool and ADSupplyPlanner.isWaitMarker(marker) then
            local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id)
            if wayPoint ~= nil then
                table.insert(candidates, {marker = marker, x = wayPoint.x, z = wayPoint.z})
            end
        end
    end
    return candidates
end

local function getWaitPoolGroup(vehicle)
    local run = ADRunsController ~= nil and ADRunsController.getRunForVehicle(vehicle) or nil
    return run ~= nil and run.waitPoolGroup or nil
end

og i startWaiting erstat getWaitCandidates(adEnv) med getWaitCandidates(adEnv, getWaitPoolGroup(vehicle)). Udvid WARN-teksten: ... intet ledigt Wait-punkt%s med vejnet ... hvor %s er " i puljen '" .. pool .. "'" når puljen er sat, ellers "".

modDesc.xml — extraSourceFiles i rækkefølge: adSupplyPlanner.lua, adRuns.lua, adRunsStorage.lua, adRunsController.lua, adUnloadWait.lua, adSmartPickup.lua.

  • Step 4: Kør alle tests

Kør: cd ~/fs25-adsmartpickup && for t in tests/test_*.lua; do luajit $t | tail -1; done Forventet: ALLE TESTS OK ×4.

  • Step 5: Commit
cd ~/fs25-adsmartpickup && git add -A FS25_ADSmartPickup tests && git commit -q -m "feat: run-traktorer får ingredienser og Wait-pulje fra deres run"

Task 6a: Controller-opslag til GUI'et (accepterede varer, Wait-reservationer)

Files:

  • Modify: FS25_ADSmartPickup/adRunsController.lua (nye funktioner), FS25_ADSmartPickup/adUnloadWait.lua (ny getClaims)
  • Test: tests/test_adRunsController.lua (C10), tests/test_adSmartPickup.lua (W11)

Interfaces:

  • Produces:

    • ADRunsController.getTargetAcceptedFillTypes(adEnv, run) -> {{index, name, title}, ...} (alle fillTypes målet tager, sorteret på title; tom uden mål)
    • ADRunsController.getVehicleLabel(adEnv, vehicle) -> "Fastrac 2135 4WS (#3)" (navn + løbenummer i farmens AD-køretøjsliste)
    • ADUnloadWait.getClaims() -> {{marker = marker, vehicle = vehicle}, ...} sorteret på marker.group, marker.id
  • Step 1: Fejlende tests

tests/test_adRunsController.lua — udvid g_fillTypeManager-mock'en øverst med:

    getFillTypes = function()
        return {
            {index = STRAW, name = "STRAW", title = "Halm"},
            {index = TMR, name = "FORAGE", title = "TMR"},
            {index = WATER, name = "WATER", title = "Vand"},
            {index = MILK, name = "MILK", title = "Mælk"},
        }
    end,

Indsæt før print(failures == 0 ...):

-- C10: målets accepterede varer (til afkrydsning i GUI'et) + køretøjs-label
buildWorld()
local labelA = makeVehicle("vehicleA", "Fastrac A", {STRAW, TMR}, 1000000)
local labelB = makeVehicle("vehicleB", "Fastrac B", {STRAW}, 700000)
local accRun = ADRunsController.createRun("Cow Feed")
check("C10 uden mål -> tom", #ADRunsController.getTargetAcceptedFillTypes(adEnv, ADRuns.getRun(ADRunsController.store, accRun.id)), 0)
ADRunsController.updateRun(accRun.id, {targetWayPointId = 94})
local accepted = ADRunsController.getTargetAcceptedFillTypes(adEnv, ADRuns.getRun(ADRunsController.store, accRun.id))
check("C10 tre varer (halm, TMR, vand)", #accepted, 3)
check("C10 sorteret på titel", accepted[1].title .. "," .. accepted[2].title .. "," .. accepted[3].title, "Halm,TMR,Vand")
check("C10 navn med", accepted[1].name, "STRAW")
check("C10 label", ADRunsController.getVehicleLabel(adEnv, labelB), "Fastrac B (#2)")

tests/test_adSmartPickup.lua — indsæt efter W-blokken (før -- ==== I:):

-- W11: reservationer kan aflæses (GUI'ets Wait-puljer-visning)
buildWaitWorld()
local vc1, tc1 = makeUnloadingVehicle("Fastrac Claim", 531, 5000, 35000)
UnloadTask.update(tc1, 16)
local claims = ADUnloadWait.getClaims()
check("W11 én reservation", #claims, 1)
check("W11 markør", claims[1].marker.id, 40)
check("W11 køretøj", claims[1].vehicle, vc1)
  • Step 2: Kør og se dem fejle

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adRunsController.lua | grep -E "FAIL|error"; luajit tests/test_adSmartPickup.lua 2>&1 | grep -E "FAIL|error" Forventet: attempt to call field 'getTargetAcceptedFillTypes' og 'getClaims' (a nil value).

  • Step 3: Implementér

adRunsController.lua — tilføj efter getRunIngredients:

-- Alle varer målet tager (uanset vogn) — afkrydsningslisten i GUI'et. {index, name, title}, sorteret på titel.
function ADRunsController.getTargetAcceptedFillTypes(adEnv, run)
    if run.targetWayPointId == nil then
        return {}
    end
    local farmId = g_currentMission:getFarmId()
    local target = ADSmartPickup.getUnloadStationAtWayPointId(adEnv, run.targetWayPointId, farmId)
    if target == nil then
        return {}
    end
    local fillTypes = g_fillTypeManager.getFillTypes ~= nil and g_fillTypeManager:getFillTypes() or g_fillTypeManager.fillTypes or {}
    local result = {}
    for _, fillType in ipairs(fillTypes) do
        if fillType.index ~= nil and ADSmartPickup.getStationLevelAndCapacity(target, fillType.index, farmId) ~= nil then
            table.insert(result, {index = fillType.index, name = fillType.name, title = fillType.title or fillType.name})
        end
    end
    table.sort(result, function(a, b) return tostring(a.title) < tostring(b.title) end)
    return result
end

-- Fire traktorer kan hedde det samme; løbenummeret er pladsen i farmens AD-køretøjsliste.
function ADRunsController.getVehicleLabel(adEnv, vehicle)
    for index, candidate in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
        if candidate == vehicle then
            return string.format("%s (#%d)", tostring(vehicle:getName()), index)
        end
    end
    return tostring(vehicle:getName())
end

adUnloadWait.lua — tilføj før ADUnloadWait.install:

-- Hvem holder hvor (GUI'ets Wait-puljer-visning). Sorteret på mappe, dernæst markør-id.
function ADUnloadWait.getClaims()
    local result = {}
    for _, wait in pairs(waits) do
        if wait.waitMarker ~= nil and claims[wait.waitMarker.id] ~= nil then
            table.insert(result, {marker = wait.waitMarker, vehicle = claims[wait.waitMarker.id]})
        end
    end
    table.sort(result, function(a, b)
        if a.marker.group ~= b.marker.group then
            return tostring(a.marker.group) < tostring(b.marker.group)
        end
        return a.marker.id < b.marker.id
    end)
    return result
end
  • Step 4: Kør alle tests — cd ~/fs25-adsmartpickup && for t in tests/test_*.lua; do luajit $t | tail -1; done → ALLE TESTS OK ×4.

  • Step 5: Commit

cd ~/fs25-adsmartpickup && git add -A FS25_ADSmartPickup tests && git commit -q -m "feat: controller-opslag til GUI (målets varer, køretøjs-label, Wait-reservationer)"

Task 6b: ESC-fanen "Smart Pickup" (GUI + l10n)

Ingen luajit-test (kræver spillets GUI-klasser); verificeres in-game i Task 7. Forbillede verificeret i FS25_FarmOperationsDashboard (fixInGameMenu) og FS25_AutoDrive (indstillings-rækker).

Files:

  • Create: FS25_ADSmartPickup/gui/SmartPickupFrame.lua, FS25_ADSmartPickup/gui/SmartPickupFrame.xml, FS25_ADSmartPickup/gui/guiProfiles.xml, FS25_ADSmartPickup/l10n/l10n_da.xml, FS25_ADSmartPickup/l10n/l10n_en.xml
  • Modify: FS25_ADSmartPickup/adSmartPickup.lua (loadMap → registrér fanen), FS25_ADSmartPickup/modDesc.xml (<l10n filenamePrefix="l10n/l10n"/>)

Interfaces:

  • Consumes: ADRunsController.* (Task 4 + 6a), ADUnloadWait.getClaims(), ADSupplyPlanner.isWaitMarker.
  • Verificerede spil-API'er: Class(Sub, TabbedMenuFrameElement), Frame:superClass().new(nil, mt), self:setMenuButtonInfo({...}) med {inputAction = InputAction.MENU_BACK|MENU_ACTIVATE|MENU_EXTRA_1|MENU_EXTRA_2|MENU_CANCEL, text = ..., callback = fn}, g_gui:loadProfiles(path), g_gui:loadGui(xmlPath, name, frame, true), g_gui.screenControllers[InGameMenu] + pagingElement:addElement/updateAbsolutePosition/updatePageMapping, inGameMenu:registerPage(frame, position, predicate), inGameMenu:addPageTab(frame, iconFile, GuiUtils.getUVs(uvs)), inGameMenu:rebuildTabList(), element:clone(parent), element:unlinkElement(), element:delete(), FocusManager:removeElement/loadElementFromCustomValues/setFocus, parent:invalidateLayout(), MultiTextOption:setTexts(list), :setState(index, true), onClick="metode" → metode(self, state, element), element:setDisabled(bool), Text:setText(str), TextInputDialog.show(callback, target, defaultText, title, description, maxChars, okText) → callback(target, value, clickOk), YesNoDialog.show(callback, target, text, title) → callback(target, yes), g_i18n:getText(key), Utils.getFilename(rel, dir).

Layout (én rullende indstillingsside; spec'ens under-faner er slået sammen: Wait-puljerne vises som skrivebeskyttede rækker nederst — mindre GUI-risiko, samme information):

┌ Smart Pickup ────────────────────────────────────────────────┐
│ Run:            [◄ Cow Feed (2/2) ►]        (vælger run)      │
│ Mål:            [◄ Cow 1 Food ►]                              │
│ Varer:          [◄ Auto ►]  / Vælg                            │
│   Halm          [◄ Ja ►]      (kun ved Vælg)                  │
│   TMR           [◄ Ja ►]                                      │
│ Wait-pulje:     [◄ Cow Wait (6) ►]                            │
│ Ture:           [◄ 0 = uendeligt ►]                           │
│ Traktorer:                                                    │
│   Fastrac 2135 4WS (#1) — 1.000.000 l — ✔   [◄ Ja ►]          │
│   Fastrac 2135 4WS (#2) — ingen vogn ✖      [◄ Nej ►]         │
│ Wait-puljer: Cow Wait — 6 punkter — 1 optaget (Fastrac #1)    │
│ Status: Kører (2/2)                                           │
├───────────────────────────────────────────────────────────────┤
│ [Tilbage] [Start run] [Nyt run] [Omdøb] [Slet run]           │
└───────────────────────────────────────────────────────────────┘
  • Step 1: l10n

FS25_ADSmartPickup/l10n/l10n_da.xml:

<?xml version="1.0" encoding="utf-8" standalone="no"?>
<l10n>
    <texts>
        <text name="spu_title" text="Smart Pickup"/>
        <text name="spu_run" text="Run"/>
        <text name="spu_run_tooltip" text="Vælg det run du vil se eller ændre."/>
        <text name="spu_noRuns" text="Ingen runs — tryk 'Nyt run'"/>
        <text name="spu_target" text="Mål"/>
        <text name="spu_target_tooltip" text="AutoDrive-markør ved stalden eller fabrikken der skal fyldes."/>
        <text name="spu_noTarget" text="(vælg mål)"/>
        <text name="spu_ingredientMode" text="Varer"/>
        <text name="spu_ingredientMode_tooltip" text="Auto: alt målet tager (fabrik: de tændte produktioners inputs). Vælg: afkryds selv."/>
        <text name="spu_mode_auto" text="Auto"/>
        <text name="spu_mode_manual" text="Vælg"/>
        <text name="spu_waitPool" text="Wait-pulje"/>
        <text name="spu_waitPool_tooltip" text="Mappe med Wait-markører, hvor traktorer med rest-læs venter."/>
        <text name="spu_waitPool_all" text="Alle (nærmeste ledige)"/>
        <text name="spu_loops" text="Ture"/>
        <text name="spu_loops_tooltip" text="Antal ture pr. traktor; 0 = uendeligt."/>
        <text name="spu_loops_infinite" text="0 = uendeligt"/>
        <text name="spu_vehicles" text="Traktorer"/>
        <text name="spu_vehicle_tooltip" text="Ja = traktoren er med i dette run."/>
        <text name="spu_waitPools" text="Wait-puljer"/>
        <text name="spu_waitPoolRow" text="%s — %d punkter — %d optaget%s"/>
        <text name="spu_status" text="Status"/>
        <text name="spu_status_running" text="Kører (%d/%d)"/>
        <text name="spu_status_stopped" text="Stoppet (%d traktorer)"/>
        <text name="spu_status_started" text="Startet %d, sprunget over %d"/>
        <text name="spu_status_noAd" text="AutoDrive-API ikke fundet"/>
        <text name="spu_status_error" text="Fejl: %s"/>
        <text name="spu_reason_noTrailer" text="ingen vogn"/>
        <text name="spu_reason_cannotCarry" text="vognen kan ikke bære run'ets varer"/>
        <text name="spu_reason_farFromNetwork" text="for langt fra vejnettet"/>
        <text name="spu_reason_noTarget" text="intet mål"/>
        <text name="spu_reason_error" text="fejl ved start (se log)"/>
        <text name="spu_movedFrom" text="flyttet fra '%s'"/>
        <text name="spu_button_start" text="Start run"/>
        <text name="spu_button_stop" text="Stop run"/>
        <text name="spu_button_new" text="Nyt run"/>
        <text name="spu_button_rename" text="Omdøb"/>
        <text name="spu_button_delete" text="Slet run"/>
        <text name="spu_newRunName" text="Run %d"/>
        <text name="spu_renameTitle" text="Navn på run"/>
        <text name="spu_deleteConfirm" text="Slet run '%s'? Traktorerne stoppes ikke."/>
        <text name="spu_capacity" text="%s l"/>
    </texts>
</l10n>

FS25_ADSmartPickup/l10n/l10n_en.xml — samme nøgler, engelsk:

<?xml version="1.0" encoding="utf-8" standalone="no"?>
<l10n>
    <texts>
        <text name="spu_title" text="Smart Pickup"/>
        <text name="spu_run" text="Run"/>
        <text name="spu_run_tooltip" text="Select the run to view or edit."/>
        <text name="spu_noRuns" text="No runs — press 'New run'"/>
        <text name="spu_target" text="Target"/>
        <text name="spu_target_tooltip" text="AutoDrive marker at the barn or factory to keep filled."/>
        <text name="spu_noTarget" text="(choose target)"/>
        <text name="spu_ingredientMode" text="Goods"/>
        <text name="spu_ingredientMode_tooltip" text="Auto: everything the target accepts (factory: inputs of enabled productions). Select: tick them yourself."/>
        <text name="spu_mode_auto" text="Auto"/>
        <text name="spu_mode_manual" text="Select"/>
        <text name="spu_waitPool" text="Wait pool"/>
        <text name="spu_waitPool_tooltip" text="Folder of Wait markers where tractors with leftover load wait."/>
        <text name="spu_waitPool_all" text="All (nearest free)"/>
        <text name="spu_loops" text="Loops"/>
        <text name="spu_loops_tooltip" text="Trips per tractor; 0 = endless."/>
        <text name="spu_loops_infinite" text="0 = endless"/>
        <text name="spu_vehicles" text="Tractors"/>
        <text name="spu_vehicle_tooltip" text="Yes = the tractor belongs to this run."/>
        <text name="spu_waitPools" text="Wait pools"/>
        <text name="spu_waitPoolRow" text="%s — %d points — %d occupied%s"/>
        <text name="spu_status" text="Status"/>
        <text name="spu_status_running" text="Running (%d/%d)"/>
        <text name="spu_status_stopped" text="Stopped (%d tractors)"/>
        <text name="spu_status_started" text="Started %d, skipped %d"/>
        <text name="spu_status_noAd" text="AutoDrive API not found"/>
        <text name="spu_status_error" text="Error: %s"/>
        <text name="spu_reason_noTrailer" text="no trailer"/>
        <text name="spu_reason_cannotCarry" text="trailer cannot carry the run's goods"/>
        <text name="spu_reason_farFromNetwork" text="too far from the road network"/>
        <text name="spu_reason_noTarget" text="no target"/>
        <text name="spu_reason_error" text="start failed (see log)"/>
        <text name="spu_movedFrom" text="moved from '%s'"/>
        <text name="spu_button_start" text="Start run"/>
        <text name="spu_button_stop" text="Stop run"/>
        <text name="spu_button_new" text="New run"/>
        <text name="spu_button_rename" text="Rename"/>
        <text name="spu_button_delete" text="Delete run"/>
        <text name="spu_newRunName" text="Run %d"/>
        <text name="spu_renameTitle" text="Run name"/>
        <text name="spu_deleteConfirm" text="Delete run '%s'? Tractors are not stopped."/>
        <text name="spu_capacity" text="%s l"/>
    </texts>
</l10n>

modDesc.xml: tilføj <l10n filenamePrefix="l10n/l10n"/> efter <multiplayer .../>.

  • Step 2: Profiler

FS25_ADSmartPickup/gui/guiProfiles.xml:

<?xml version="1.0" encoding="utf-8" standalone="no"?>
<GUIProfiles>
    <Profile name="spuMenuHeaderIcon" extends="baseReference" with="anchorMiddleCenter">
        <size value="50px 50px"/>
        <imageSliceId value="gui.icon_ingameMenu_vehicles"/>
        <imageColor value="$preset_fs25_colorMainDark"/>
    </Profile>
    <Profile name="spuTabContainer" extends="emptyPanel" with="anchorStretchingYStretchingX pivotTopLeft">
        <position value="0px -90px"/>
        <absoluteSizeOffset value="0px 90px"/>
    </Profile>
    <Profile name="spuSectionTitle" extends="fs25_settingsMultiTextOptionTitle">
        <textBold value="true"/>
        <textUpperCase value="true"/>
    </Profile>
    <Profile name="spuInfoText" extends="fs25_settingsMultiTextOptionTitle">
        <textBold value="false"/>
        <size value="1400px 40px"/>
    </Profile>
</GUIProfiles>

(gui.icon_ingameMenu_vehicles er et basisspil-atlas-slice ligesom FOD's gui.icon_ingameMenu_statistics; virker det ikke, byt til gui.icon_ingameMenu_statistics som er bekræftet.)

  • Step 3: Frame-XML

FS25_ADSmartPickup/gui/SmartPickupFrame.xml:

<?xml version="1.0" encoding="utf-8" standalone="no"?>
<GUI name="smartPickupFrame">
    <GuiElement profile="fs25_menuContainer">
        <GuiElement profile="fs25_menuHeaderPanel">
            <Bitmap profile="fs25_menuHeaderIconBg">
                <Bitmap profile="spuMenuHeaderIcon"/>
            </Bitmap>
            <Text profile="fs25_menuHeaderTitle" text="$l10n_spu_title"/>
        </GuiElement>

        <GuiElement profile="spuTabContainer">
            <ScrollingLayout profile="fs25_settingsLayout" id="boxLayout">
                <Bitmap profile="fs25_multiTextOptionContainer">
                    <MultiTextOption profile="fs25_settingsMultiTextOption" onClick="onClickRun" id="runOption">
                        <Text profile="fs25_multiTextOptionTooltip" name="ignore" text="$l10n_spu_run_tooltip"/>
                    </MultiTextOption>
                    <Text profile="fs25_settingsMultiTextOptionTitle" text="$l10n_spu_run"/>
                </Bitmap>
                <Bitmap profile="fs25_multiTextOptionContainer">
                    <MultiTextOption profile="fs25_settingsMultiTextOption" onClick="onClickTarget" id="targetOption">
                        <Text profile="fs25_multiTextOptionTooltip" name="ignore" text="$l10n_spu_target_tooltip"/>
                    </MultiTextOption>
                    <Text profile="fs25_settingsMultiTextOptionTitle" text="$l10n_spu_target"/>
                </Bitmap>
                <Bitmap profile="fs25_multiTextOptionContainer">
                    <BinaryOption profile="fs25_settingsBinaryOption" onClick="onClickIngredientMode" id="ingredientModeOption">
                        <Text profile="fs25_multiTextOptionTooltip" name="ignore" text="$l10n_spu_ingredientMode_tooltip"/>
                    </BinaryOption>
                    <Text profile="fs25_settingsMultiTextOptionTitle" text="$l10n_spu_ingredientMode"/>
                </Bitmap>
                <BoxLayout profile="fs25_settingsLayout" id="ingredientBox"/>
                <Bitmap profile="fs25_multiTextOptionContainer">
                    <MultiTextOption profile="fs25_settingsMultiTextOption" onClick="onClickWaitPool" id="waitPoolOption">
                        <Text profile="fs25_multiTextOptionTooltip" name="ignore" text="$l10n_spu_waitPool_tooltip"/>
                    </MultiTextOption>
                    <Text profile="fs25_settingsMultiTextOptionTitle" text="$l10n_spu_waitPool"/>
                </Bitmap>
                <Bitmap profile="fs25_multiTextOptionContainer">
                    <MultiTextOption profile="fs25_settingsMultiTextOption" onClick="onClickLoops" id="loopsOption">
                        <Text profile="fs25_multiTextOptionTooltip" name="ignore" text="$l10n_spu_loops_tooltip"/>
                    </MultiTextOption>
                    <Text profile="fs25_settingsMultiTextOptionTitle" text="$l10n_spu_loops"/>
                </Bitmap>
                <Bitmap profile="fs25_multiTextOptionContainer">
                    <Text profile="spuSectionTitle" text="$l10n_spu_vehicles"/>
                </Bitmap>
                <BoxLayout profile="fs25_settingsLayout" id="vehicleBox"/>
                <Bitmap profile="fs25_multiTextOptionContainer">
                    <Text profile="spuSectionTitle" text="$l10n_spu_waitPools"/>
                </Bitmap>
                <BoxLayout profile="fs25_settingsLayout" id="poolBox"/>
                <Bitmap profile="fs25_multiTextOptionContainer">
                    <Text profile="spuInfoText" id="statusText"/>
                </Bitmap>
            </ScrollingLayout>

            <!-- prefabs: klones pr. vare / traktor / pulje og fjernes fra layoutet ved initialize -->
            <Bitmap profile="fs25_multiTextOptionContainer" id="ingredientPrefab">
                <BinaryOption profile="fs25_settingsBinaryOption" onClick="onClickIngredient" name="option"/>
                <Text profile="fs25_settingsMultiTextOptionTitle" name="title"/>
            </Bitmap>
            <Bitmap profile="fs25_multiTextOptionContainer" id="vehiclePrefab">
                <BinaryOption profile="fs25_settingsBinaryOption" onClick="onClickVehicle" name="option">
                    <Text profile="fs25_multiTextOptionTooltip" name="ignore" text="$l10n_spu_vehicle_tooltip"/>
                </BinaryOption>
                <Text profile="fs25_settingsMultiTextOptionTitle" name="title"/>
            </Bitmap>
            <Bitmap profile="fs25_multiTextOptionContainer" id="poolPrefab">
                <Text profile="spuInfoText" name="title"/>
            </Bitmap>
        </GuiElement>
    </GuiElement>
</GUI>
  • Step 4: Frame-Lua

FS25_ADSmartPickup/gui/SmartPickupFrame.lua:

-- Smart Pickup — ESC-fane
-- Viser og redigerer runs og starter/stopper dem. Al logik ligger i ADRunsController;
-- her oversættes kun mellem GUI-elementer og run-felter. Alt kører i pcall så en GUI-fejl
-- aldrig tager spillet med sig.

SmartPickupFrame = {}
SmartPickupFrame._mt = Class(SmartPickupFrame, TabbedMenuFrameElement)

SmartPickupFrame.REFRESH_INTERVAL_MS = 1000
SmartPickupFrame.LOG_PREFIX = "ADSmartPickup: "
SmartPickupFrame.STATE_OFF, SmartPickupFrame.STATE_ON = 1, 2

function SmartPickupFrame.new(i18n, getAdEnv)
    local self = SmartPickupFrame:superClass().new(nil, SmartPickupFrame._mt)
    self.name = "SmartPickupFrame"
    self.i18n = i18n
    self.getAdEnv = getAdEnv
    self.selectedRunId = nil
    self.runIds, self.targetIds, self.poolGroups = {}, {}, {}
    self.rowData = {}          -- element -> {kind = "ingredient"|"vehicle", ...}
    self.clonedRows = {}
    self.refreshTimer = 0
    self.statusOverride = nil
    self.backButtonInfo = {inputAction = InputAction.MENU_BACK}
    self.startButtonInfo = {inputAction = InputAction.MENU_ACTIVATE, text = i18n:getText("spu_button_start"), callback = function() self:onClickStartStop() end}
    self.newButtonInfo = {inputAction = InputAction.MENU_EXTRA_1, text = i18n:getText("spu_button_new"), callback = function() self:onClickNew() end}
    self.renameButtonInfo = {inputAction = InputAction.MENU_EXTRA_2, text = i18n:getText("spu_button_rename"), callback = function() self:onClickRename() end}
    self.deleteButtonInfo = {inputAction = InputAction.MENU_CANCEL, text = i18n:getText("spu_button_delete"), callback = function() self:onClickDelete() end}
    self:setMenuButtonInfo({self.backButtonInfo, self.startButtonInfo, self.newButtonInfo, self.renameButtonInfo, self.deleteButtonInfo})
    return self
end

function SmartPickupFrame:delete()
    SmartPickupFrame:superClass().delete(self)
end

function SmartPickupFrame:copyAttributes(src)
    SmartPickupFrame:superClass().copyAttributes(self, src)
    self.i18n, self.getAdEnv = src.i18n, src.getAdEnv
end

-- Prefabs skal ud af layoutet før de klones, ellers vises de selv som rækker.
function SmartPickupFrame:initialize()
    for _, name in ipairs({"ingredientPrefab", "vehiclePrefab", "poolPrefab"}) do
        local prefab = self[name]
        if prefab ~= nil then
            prefab:unlinkElement()
            FocusManager:removeElement(prefab)
        end
    end
    self.ingredientModeOption:setTexts({self.i18n:getText("spu_mode_auto"), self.i18n:getText("spu_mode_manual")})
    local loopTexts = {self.i18n:getText("spu_loops_infinite")}
    for loops = 1, ADRuns.MAX_LOOPS do
        table.insert(loopTexts, tostring(loops))
    end
    self.loopsOption:setTexts(loopTexts)
end

function SmartPickupFrame:onFrameOpen()
    SmartPickupFrame:superClass().onFrameOpen(self)
    self.statusOverride = nil
    self:safe(self.rebuild)
    FocusManager:setFocus(self.runOption)
end

function SmartPickupFrame:onFrameClose()
    SmartPickupFrame:superClass().onFrameClose(self)
end

-- Live-opdatering af status/traktor-rækker mens siden er åben (uden at genopbygge rækkerne).
function SmartPickupFrame:update(dt)
    SmartPickupFrame:superClass().update(self, dt)
    self.refreshTimer = self.refreshTimer + dt
    if self.refreshTimer >= SmartPickupFrame.REFRESH_INTERVAL_MS then
        self.refreshTimer = 0
        self:safe(self.refreshLive)
    end
end

function SmartPickupFrame:safe(fn, ...)
    local ok, err = pcall(fn, self, ...)
    if not ok then
        Logging.warning(SmartPickupFrame.LOG_PREFIX .. "GUI-fejl: %s", tostring(err))
        self.statusOverride = string.format(self.i18n:getText("spu_status_error"), tostring(err))
        if self.statusText ~= nil then
            self.statusText:setText(self.statusOverride)
        end
    end
end

function SmartPickupFrame:getRun()
    return self.selectedRunId ~= nil and ADRuns.getRun(ADRunsController.store, self.selectedRunId) or nil
end

-- ---- opbygning ----

function SmartPickupFrame:clearRows()
    for _, row in ipairs(self.clonedRows) do
        row:delete()
    end
    self.clonedRows, self.rowData = {}, {}
end

function SmartPickupFrame:addRow(prefab, parent, data)
    local row = prefab:clone(parent)
    FocusManager:loadElementFromCustomValues(row)
    table.insert(self.clonedRows, row)
    local option = row:getDescendantByName("option")
    if option ~= nil then
        option:setTexts({self.i18n:getText("ui_no"), self.i18n:getText("ui_yes")})
        self.rowData[option] = data
    end
    return row
end

function SmartPickupFrame:rebuild()
    local adEnv = self.getAdEnv()
    local store = ADRunsController.store
    if self.selectedRunId == nil or ADRuns.getRun(store, self.selectedRunId) == nil then
        self.selectedRunId = store.runs[1] ~= nil and store.runs[1].id or nil
    end
    self:clearRows()
    self:fillRunOption(adEnv)
    local run = self:getRun()
    local hasRun = run ~= nil and adEnv ~= nil
    for _, element in ipairs({self.targetOption, self.ingredientModeOption, self.waitPoolOption, self.loopsOption}) do
        element:setDisabled(not hasRun)
    end
    if hasRun then
        self:fillTargetOption(adEnv, run)
        self.ingredientModeOption:setState(run.ingredientMode == "manual" and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, true)
        self:fillIngredientRows(adEnv, run)
        self:fillWaitPoolOption(adEnv, run)
        self.loopsOption:setState(run.loops + 1, true)
        self:fillVehicleRows(adEnv, run)
    end
    self:fillPoolRows(adEnv)
    for _, box in ipairs({self.ingredientBox, self.vehicleBox, self.poolBox, self.boxLayout}) do
        box:invalidateLayout()
    end
    self:refreshLive()
end

function SmartPickupFrame:fillRunOption(adEnv)
    local texts, index = {}, 1
    self.runIds = {}
    for position, run in ipairs(ADRunsController.store.runs) do
        local status = adEnv ~= nil and ADRunsController.getRunStatus(adEnv, run) or {running = 0, total = #run.vehicleIds}
        table.insert(texts, string.format("%s (%d/%d)", run.name, status.running, status.total))
        table.insert(self.runIds, run.id)
        if run.id == self.selectedRunId then
            index = position
        end
    end
    if #texts == 0 then
        texts = {self.i18n:getText("spu_noRuns")}
    end
    self.runOption:setTexts(texts)
    self.runOption:setState(index, true)
    self.runOption:setDisabled(#self.runIds == 0)
end

function SmartPickupFrame:fillTargetOption(adEnv, run)
    local texts, index = {self.i18n:getText("spu_noTarget")}, 1
    self.targetIds = {false}
    for _, marker in ipairs(ADRunsController.getTargetMarkers(adEnv, g_currentMission:getFarmId())) do
        table.insert(texts, tostring(marker.name))
        table.insert(self.targetIds, marker.id)
        if marker.id == run.targetWayPointId then
            index = #texts
        end
    end
    self.targetOption:setTexts(texts)
    self.targetOption:setState(index, true)
end

function SmartPickupFrame:fillIngredientRows(adEnv, run)
    if run.ingredientMode ~= "manual" then
        return
    end
    local chosen = {}
    for _, name in ipairs(run.ingredients) do
        chosen[name] = true
    end
    for _, fillType in ipairs(ADRunsController.getTargetAcceptedFillTypes(adEnv, run)) do
        local row = self:addRow(self.ingredientPrefab, self.ingredientBox, {kind = "ingredient", name = fillType.name})
        row:getDescendantByName("title"):setText(tostring(fillType.title))
        row:getDescendantByName("option"):setState(chosen[fillType.name] and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, true)
    end
end

function SmartPickupFrame:fillWaitPoolOption(adEnv, run)
    local texts, index = {self.i18n:getText("spu_waitPool_all")}, 1
    self.poolGroups = {""}
    for _, pool in ipairs(ADRunsController.getWaitPools(adEnv)) do
        table.insert(texts, string.format("%s (%d)", pool.group, pool.count))
        table.insert(self.poolGroups, pool.group)
        if pool.group == run.waitPoolGroup then
            index = #texts
        end
    end
    self.waitPoolOption:setTexts(texts)
    self.waitPoolOption:setState(index, true)
end

function SmartPickupFrame:fillVehicleRows(adEnv, run)
    local inRun = {}
    for _, id in ipairs(run.vehicleIds) do
        inRun[id] = true
    end
    for _, vehicle in ipairs(ADRunsController.getFarmVehicles(adEnv)) do
        local id = ADRunsController.getVehicleId(vehicle)
        local row = self:addRow(self.vehiclePrefab, self.vehicleBox, {kind = "vehicle", vehicle = vehicle, row = nil})
        self.rowData[row:getDescendantByName("option")].row = row
        row:getDescendantByName("option"):setState(inRun[id] and SmartPickupFrame.STATE_ON or SmartPickupFrame.STATE_OFF, true)
        row:getDescendantByName("title"):setText(self:describeVehicle(adEnv, run, vehicle, inRun[id]))
    end
end

-- "Fastrac 2135 4WS (#1) — 1.000.000 l — ✔" / "… — ✖ ingen vogn"; reservationsnote fra Start bevares.
function SmartPickupFrame:describeVehicle(adEnv, run, vehicle, isInRun)
    local label = ADRunsController.getVehicleLabel(adEnv, vehicle)
    if not isInRun then
        return label
    end
    local validation = ADRunsController.validateVehicle(adEnv, run, vehicle)
    local capacity = validation.capacity ~= nil and string.format(self.i18n:getText("spu_capacity"), g_i18n:formatNumber(validation.capacity, 0)) or nil
    local parts = {label}
    if capacity ~= nil then
        table.insert(parts, capacity)
    end
    if validation.ok then
        table.insert(parts, "✔")
    else
        table.insert(parts, "✖ " .. self.i18n:getText("spu_reason_" .. tostring(validation.reason)))
    end
    return table.concat(parts, " — ")
end

function SmartPickupFrame:fillPoolRows(adEnv)
    if adEnv == nil then
        return
    end
    local occupied = {}
    for _, claim in ipairs(ADUnloadWait.getClaims()) do
        occupied[claim.marker.group] = occupied[claim.marker.group] or {}
        table.insert(occupied[claim.marker.group], ADRunsController.getVehicleLabel(adEnv, claim.vehicle))
    end
    for _, pool in ipairs(ADRunsController.getWaitPools(adEnv)) do
        local names = occupied[pool.group] or {}
        local suffix = #names > 0 and (" (" .. table.concat(names, ", ") .. ")") or ""
        local row = self:addRow(self.poolPrefab, self.poolBox, {kind = "pool"})
        row:getDescendantByName("title"):setText(string.format(self.i18n:getText("spu_waitPoolRow"), pool.group, pool.count, #names, suffix))
    end
end

-- Status + Start/Stop-knappens tekst, kaldes hvert sekund.
function SmartPickupFrame:refreshLive()
    local adEnv = self.getAdEnv()
    local run = self:getRun()
    if adEnv == nil then
        self.statusText:setText(self.i18n:getText("spu_status_noAd"))
        return
    end
    local isRunning = false
    if run ~= nil then
        local status = ADRunsController.getRunStatus(adEnv, run)
        isRunning = status.running > 0
        local text = isRunning and string.format(self.i18n:getText("spu_status_running"), status.running, status.total)
            or string.format(self.i18n:getText("spu_status_stopped"), status.total)
        self.statusText:setText(self.statusOverride or text)
        for option, data in pairs(self.rowData) do
            if data.kind == "vehicle" and data.row ~= nil then
                data.row:getDescendantByName("title"):setText(self:describeVehicle(adEnv, run, data.vehicle, option:getState() == SmartPickupFrame.STATE_ON))
            end
        end
    else
        self.statusText:setText(self.statusOverride or self.i18n:getText("spu_noRuns"))
    end
    self.startButtonInfo.text = self.i18n:getText(isRunning and "spu_button_stop" or "spu_button_start")
    self.startButtonInfo.disabled = run == nil
    self.renameButtonInfo.disabled = run == nil
    self.deleteButtonInfo.disabled = run == nil
    self:setMenuButtonInfoDirty()
end

-- ---- callbacks fra XML (state = 1-baseret indeks; element = det klikkede element) ----

function SmartPickupFrame:onClickRun(state)
    self.selectedRunId = self.runIds[state]
    self.statusOverride = nil
    self:safe(self.rebuild)
end

function SmartPickupFrame:onClickTarget(state)
    local run = self:getRun()
    if run == nil then return end
    -- targetIds[1] er false = "(vælg mål)"; modellen tolker false som "ryd målet"
    ADRunsController.updateRun(run.id, {targetWayPointId = self.targetIds[state] or false})
    self:safe(self.rebuild)
end

function SmartPickupFrame:onClickIngredientMode(state)
    local run = self:getRun()
    if run == nil then return end
    ADRunsController.updateRun(run.id, {ingredientMode = state == SmartPickupFrame.STATE_ON and "manual" or "auto"})
    self:safe(self.rebuild)
end

function SmartPickupFrame:onClickIngredient(state, element)
    local run, data = self:getRun(), self.rowData[element]
    if run == nil or data == nil then return end
    local names = {}
    for _, name in ipairs(run.ingredients) do
        if name ~= data.name then table.insert(names, name) end
    end
    if state == SmartPickupFrame.STATE_ON then
        table.insert(names, data.name)
    end
    ADRunsController.updateRun(run.id, {ingredients = names})
end

function SmartPickupFrame:onClickWaitPool(state)
    local run = self:getRun()
    if run == nil then return end
    ADRunsController.updateRun(run.id, {waitPoolGroup = self.poolGroups[state] or ""})
end

function SmartPickupFrame:onClickLoops(state)
    local run = self:getRun()
    if run == nil then return end
    ADRunsController.updateRun(run.id, {loops = state - 1})
end

function SmartPickupFrame:onClickVehicle(state, element)
    local run, data = self:getRun(), self.rowData[element]
    if run == nil or data == nil then return end
    if state == SmartPickupFrame.STATE_ON then
        local previousRunId = ADRunsController.assignVehicle(run.id, data.vehicle)
        if previousRunId ~= nil then
            local previous = ADRuns.getRun(ADRunsController.store, previousRunId)
            self.statusOverride = string.format(self.i18n:getText("spu_movedFrom"), previous ~= nil and previous.name or tostring(previousRunId))
        end
    else
        ADRunsController.unassignVehicle(data.vehicle)
    end
    self:safe(self.refreshLive)
end

-- ---- knapper i bundlinjen ----

function SmartPickupFrame:onClickStartStop()
    self:safe(function(frame)
        local adEnv, run = frame.getAdEnv(), frame:getRun()
        if adEnv == nil or run == nil then return end
        if ADRunsController.getRunStatus(adEnv, run).running > 0 then
            ADRunsController.stopRun(adEnv, run.id)
            frame.statusOverride = nil
        else
            local started, skipped = 0, 0
            for _, result in ipairs(ADRunsController.startRun(adEnv, run.id)) do
                if result.ok then started = started + 1 else skipped = skipped + 1 end
            end
            frame.statusOverride = string.format(frame.i18n:getText("spu_status_started"), started, skipped)
        end
        ADRunsController.save()
        frame:rebuild()
    end)
end

function SmartPickupFrame:onClickNew()
    self:safe(function(frame)
        local run = ADRunsController.createRun(string.format(frame.i18n:getText("spu_newRunName"), ADRunsController.store.nextId - 1))
        frame.selectedRunId = run.id
        frame.statusOverride = nil
        frame:rebuild()
    end)
end

function SmartPickupFrame:onClickRename()
    local run = self:getRun()
    if run == nil then return end
    TextInputDialog.show(SmartPickupFrame.onRenameResult, self, run.name, self.i18n:getText("spu_renameTitle"), nil, 30, g_i18n:getText("button_ok"))
end

function SmartPickupFrame:onRenameResult(value, clickOk)
    local run = self:getRun()
    if clickOk and run ~= nil and value ~= nil and value:match("%S") then
        ADRunsController.updateRun(run.id, {name = value})
        self:safe(self.rebuild)
    end
end

function SmartPickupFrame:onClickDelete()
    local run = self:getRun()
    if run == nil then return end
    YesNoDialog.show(SmartPickupFrame.onDeleteResult, self, string.format(self.i18n:getText("spu_deleteConfirm"), run.name), self.i18n:getText("spu_button_delete"))
end

function SmartPickupFrame:onDeleteResult(yes)
    local run = self:getRun()
    if yes and run ~= nil then
        ADRunsController.deleteRun(run.id)
        self.selectedRunId = nil
        self:safe(self.rebuild)
    end
end

Kendte usikkerheder (verificeres in-game, rettes hvis nødvendigt): setMenuButtonInfoDirty() er den FS22/25-metode TabbedMenuFrameElement bruger til at gentegne knapperne; findes den ikke, erstat med self:setMenuButtonInfo(self.menuButtonInfo). option:getState() på BinaryOption; findes den ikke, brug option.state. g_i18n:formatNumber(n, 0) findes i FS25 (I18N:formatNumber).

  • Step 5: Registrér fanen fra adSmartPickup.lua

Tilføj øverst i adSmartPickup.lua (efter ADSmartPickup = {}):

ADSmartPickup.dir = g_currentModDirectory
if ADSmartPickup.dir ~= nil and source ~= nil then
    source(ADSmartPickup.dir .. "gui/SmartPickupFrame.lua")
end

Tilføj funktionen (før ADSmartPickup:update):

-- Indsætter fanen i ESC-menuen foran Statistik/Priser (mønster fra FS25_FarmOperationsDashboard).
local function insertInGameMenuPage(frame, pageName, iconFile, uvs, position, predicate)
    local inGameMenu = g_gui ~= nil and g_gui.screenControllers ~= nil and g_gui.screenControllers[InGameMenu] or nil
    if inGameMenu == nil or inGameMenu.pagingElement == nil then
        return false
    end
    local insertAt = position
    for i = 1, #inGameMenu.pagingElement.elements do
        local child = inGameMenu.pagingElement.elements[i]
        if child == inGameMenu.pageStatistics or child == inGameMenu.pagePrices then
            insertAt = i
            break
        end
    end
    if inGameMenu.controlIDs ~= nil then
        inGameMenu.controlIDs[pageName] = nil
    end
    inGameMenu[pageName] = frame
    inGameMenu.pagingElement:addElement(frame)
    if inGameMenu.exposeControlsAsFields ~= nil then
        inGameMenu:exposeControlsAsFields(pageName)
    end
    for _, list in ipairs({inGameMenu.pagingElement.elements, inGameMenu.pageFrames}) do
        for i = 1, #list do
            if list[i] == frame then
                table.remove(list, i)
                table.insert(list, insertAt, frame)
                break
            end
        end
    end
    for i = 1, #inGameMenu.pagingElement.pages do
        if inGameMenu.pagingElement.pages[i].element == frame then
            local page = table.remove(inGameMenu.pagingElement.pages, i)
            table.insert(inGameMenu.pagingElement.pages, insertAt, page)
            break
        end
    end
    inGameMenu.pagingElement:updateAbsolutePosition()
    inGameMenu.pagingElement:updatePageMapping()
    inGameMenu:registerPage(frame, position, predicate)
    inGameMenu:addPageTab(frame, iconFile, GuiUtils.getUVs(uvs))
    inGameMenu:rebuildTabList()
    return true
end

local function loadGui()
    if g_gui == nil or SmartPickupFrame == nil or ADSmartPickup.dir == nil then
        return
    end
    g_gui:loadProfiles(ADSmartPickup.dir .. "gui/guiProfiles.xml")
    local frame = SmartPickupFrame.new(g_i18n, getAutoDriveEnv)
    g_gui:loadGui(ADSmartPickup.dir .. "gui/SmartPickupFrame.xml", "smartPickupFrame", frame, true)
    local iconFile = Utils.getFilename("icon.dds", ADSmartPickup.dir)
    if not insertInGameMenuPage(frame, "smartPickupFrame", iconFile, {0, 0, 512, 512}, 2, function() return g_currentMission ~= nil end) then
        Logging.warning(ADSmartPickup.LOG_PREFIX .. "kunne ikke indsætte fanen i ESC-menuen")
        return
    end
    frame:initialize()
    ADSmartPickup.guiFrame = frame
    Logging.info(ADSmartPickup.LOG_PREFIX .. "ESC-fane 'Smart Pickup' registreret")
end

function ADSmartPickup:loadMap()
    local ok, err = pcall(loadGui)
    if not ok then
        Logging.warning(ADSmartPickup.LOG_PREFIX .. "GUI kunne ikke indlæses (resten af moden virker): %s", tostring(err))
    end
end

icon.dds er 512×512 (verificeret) — derfor UVs {0, 0, 512, 512}. Ikonet er modhub-ikonet og kan se tungt ud i fanelinjen; et dedikeret hvidt 1024×1024-ikon kan laves senere uden kodeændring ud over stien/UVs.

  • Step 6: Syntakstjek + tests
cd ~/fs25-adsmartpickup && luajit -bl FS25_ADSmartPickup/gui/SmartPickupFrame.lua > /dev/null && luajit -bl FS25_ADSmartPickup/adSmartPickup.lua > /dev/null && echo SYNTAX-OK && for t in tests/test_*.lua; do luajit $t | tail -1; done

Forventet: SYNTAX-OK + ALLE TESTS OK ×4 (testen kalder ikke loadMap; g_currentModDirectory er nil i luajit, så source springes over).

  • Step 7: Commit
cd ~/fs25-adsmartpickup && git add -A FS25_ADSmartPickup && git commit -q -m "feat: ESC-fane 'Smart Pickup' (runs: mål, varer, Wait-pulje, ture, traktorer, Start/Stop)"

Task 7: Udgivelse v1.8.0.0 (README, modDesc, byg, hukommelse)

Files:

  • Modify: FS25_ADSmartPickup/modDesc.xml (<version>1.8.0.0</version>, beskrivelse), README.md

  • Modify: ~/.claude/projects/-var-home-masterdraco/memory/project_fs25_adsmartpickup.md, ~/obsidian/mine_projekter/projekter/FS25 silo-patches + AD Smart Pickup.md

  • Step 1: modDesc.xml

Sæt <version>1.8.0.0</version>. Tilføj til beskrivelsen (før Kræver FS25_AutoDrive): Ny ESC-fane "Smart Pickup": definér runs (mål + varer + Wait-pulje + traktorer + ture) og start/stop dem samlet.

  • Step 2: README.md

Tilføj afsnit ## v1.8 — Runs i ESC-menuen med: hvad et run er, hvordan man opretter (ESC → Smart Pickup → + Nyt run), felterne (Mål, Varer Auto/Vælg, Wait-pulje, Ture, Traktorer), Start/Stop, at ✖-traktorer springes over med grund, at runs gemmes i savegameN/ADSmartPickup_runs.xml, at runs ikke auto-genstartes ved load, og at traktorer uden run kører som før. Nævn at Start sætter AD til P&D + "kun pålæsning" + mapper til, så manuelle indstillinger på den traktor overskrives.

  • Step 3: Kør alle tests, byg, kopiér
cd ~/fs25-adsmartpickup && for t in tests/test_*.lua; do luajit $t | tail -1; done && cd FS25_ADSmartPickup && rm -f ../FS25_ADSmartPickup.zip && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/ && unzip -l ../FS25_ADSmartPickup.zip | tail -n +4 | head -20

Forventet: 4× ALLE TESTS OK; zip indeholder gui/, l10n/, alle seks lua-filer, modDesc.xml, icon.dds.

  • Step 4: Commit + push
cd ~/fs25-adsmartpickup && git add -A && git commit -q -m "feat: runs-UI i ESC-menuen (v1.8.0.0)" && TOKEN=$(grep -A8 -i "gitea" ~/credentials-vault.md | grep -oE '[0-9a-f]{40}' | head -1); git -c http.extraHeader="Authorization: token $TOKEN" push -q gitea HEAD && echo pushed
  • Step 5: In-game tjekliste (brugeren kører spillet; log i ~/FS25-data/log.txt)
  1. Start spillet, indlæs savegame 2. Log skal vise ADSmartPickup: aktiv (...) og INGEN Error:/LUA-linjer med ADSmartPickup/SmartPickupFrame.
  2. ESC → fanen "Smart Pickup" findes; Runs-listen er tom; "+ Nyt run" opretter "Run 1".
  3. Mål-dropdown viser "Cow 1 Food"/"Cow 2 Food"/"TMR Unload" (markører med aflæsningsstation), ikke siloer.
  4. Varer: Auto viser målets varer; Vælg viser afkrydsning.
  5. Wait-pulje-dropdown viser "Alle (nærmeste ledige)" + "Cow Wait (6)".
  6. Tilføj 2 Fastrac + én traktor uden vogn → uden vogn får ✖ "ingen vogn".
  7. Start run → de to Fastrac kører (log: run 'Cow Feed': 'Fastrac 2135 4WS' startet mod markør 94), traktoren uden vogn står stille; status "Kører (2/3)".
  8. Stop run → begge stopper; status "(0/3)".
  9. Gem, afslut, indlæs → run'et og traktorerne er der stadig; savegame2/ADSmartPickup_runs.xml findes.
  10. Wait-puljer-fanen viser "Cow Wait — 6 punkter" og hvem der holder hvor.
  • Step 6: Hukommelse + Obsidian

Opdatér project_fs25_adsmartpickup.md (v1.8 status: implementeret, in-game-tjekliste udestår/udført; runs-fil; afvigelser fra spec) og Obsidian-noten tilsvarende.


Task 8: v1.9 — læs-bytte fra Wait (adLoadSwap.lua)

Files:

  • Create: FS25_ADSmartPickup/adLoadSwap.lua
  • Modify: FS25_ADSmartPickup/adUnloadWait.lua:129-149 (updateWaiting), :151-158 (beforeUpdate får dt), :180-187 (install sender dt)
  • Modify: FS25_ADSmartPickup/modDesc.xml (adLoadSwap.lua før adUnloadWait.lua)
  • Test: tests/test_adSmartPickup.lua (S1–S5)

Interfaces:

  • Consumes: ADSmartPickup.getIngredientsFor(adEnv, driver), ADSmartPickup.findSupplyPickup(adEnv, driver, ingredients) (returnerer {fillType, need = {fits, ...}, ...}), ADSmartPickup.getUnloadStationAtWayPointId, ADSmartPickup.getOwnStorageLevel(station, fillTypes), ADSmartPickup.getFarmId, ADSupplyPlanner.pickNearestFreeWaitPoint-stil sortering.
  • Produces:
    • ADLoadSwap.CHECK_INTERVAL_MS = 5000
    • ADLoadSwap.findMissingIngredient(adEnv, vehicle, restFillType) -> choice|nil (anden vare i listen der mangler et FULDT læs, og som findes i en silo)
    • ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, rest, homeWayPointId, isReachable) -> marker|nil
    • ADLoadSwap.tryFromWait(adEnv, task, wait, dt, redirect, release) -> boolean (true = omdirigeret til silo)

Adfærd (spec "Efterfølgende"): mens traktoren venter, hver 5. sekund: er der en ANDEN vare i traktorens liste, som målet mangler så meget at et fuldt læs passer (need.fits == true) og som findes i en silo (findSupplyPickup fandt den)? Så find en silo (lager-placeable spec_silo, IKKE stald/fabrik/målet selv) med aflæsningsstation ved en AD-markør, der tager varen, har fri plads ≥ resten og har vejnet til (fra traktoren) og fra (til målet). Foretræk siloer der allerede har varen i eget lager, dernæst nærmeste. Omdirigér aflæsningsopgaven dertil, slip Wait-punktet, log bytter læs: N l STRAW tilbage i <markør>, målet mangler FORAGE. Ingen silo → bliv ved Wait (log én gang pr. vente-periode).

  • Step 1: Tilføj fejlende tests

I tests/test_adSmartPickup.lua tilføj dofile("FS25_ADSmartPickup/adLoadSwap.lua") lige før dofile("FS25_ADSmartPickup/adUnloadWait.lua"). S-blokken nedenfor indsættes EFTER I-blokken og FØR -- ==== E: (I-blokken slutter med ADRunsController.store = ADRuns.newStore(), så ingen traktor er i et run). Stationer skal kunne være aflæsningsmål OG silo: udvid makeStation så den returnerede tabel også får unloadTriggers = {{exactFillRootNode = node}}, getIsFillTypeSupported = function(_, fillType) return levels[fillType] ~= nil or (ownLevels ~= nil and ownLevels[fillType] ~= nil) end og getFreeCapacity = function(_, fillType) return (levels.capacity or 200000) - (levels[fillType] or 0) end, og lad placeable altid være {spec_silo = {storages = {...}}} når ownLevels ~= nil. Registrér stationen i BÅDE stations og unloadStations i buildCowWorld for halmladen (markør 21) og i en ny buildSwapWorld:

-- ==== S: læs-bytte fra Wait — halm-rest ved Wait, målet mangler TMR -> aflæs halm i silo ====
local function buildSwapWorld()
    buildWaitWorld()                       -- stald: halm FULD (50000/50000), TMR 10000/100000
    -- halmladen (markør 21) er også et aflæsningssted med plads
    table.insert(unloadStations, stations[2])
    stations[2].unloadTriggers = {{exactFillRootNode = 121}}
    stations[2].getIsFillTypeSupported = function(_, fillType) return fillType == STRAW end
    stations[2].getFreeCapacity = function() return 100000 end
    stations[2].owningPlaceable = {spec_silo = {storages = {{getFillLevel = function(_, fillType) return fillType == STRAW and 50000 or 0 end}}}}
end

-- S1: ventende traktor med halm-rest -> TMR mangler et fuldt læs (40000 l vogn, 90000 l fri) -> til halmladen
buildSwapWorld()
local sv, st = makeUnloadingVehicle("Fastrac Bytte", 521, 5000, 35000)
UnloadTask.update(st, 16)
check("S1 venter først ved Wait", st.destinationID, 40)
UnloadTask.update(st, ADLoadSwap.CHECK_INTERVAL_MS + 1)
check("S1 omdirigeret til halmladen", st.destinationID, 21)
check("S1 logget bytte", countLogsSince(1, "bytter læs") >= 1, true)
check("S1 Wait-punkt frigivet", FS25_AutoDrive.AutoDrive.getSetting("rotateTargets", sv), RT_ONLYPICKUP)

-- S2: målet mangler IKKE et fuldt læs af TMR -> bliv ved Wait
buildSwapWorld()
waitBarnLevels[TMR] = 75000 -- 25000 fri < 40000 vogn
local sv2, st2 = makeUnloadingVehicle("Fastrac Bliv", 522, 5000, 35000)
UnloadTask.update(st2, 16)
UnloadTask.update(st2, ADLoadSwap.CHECK_INTERVAL_MS + 1)
check("S2 bliver ved Wait", st2.destinationID, 40)

-- S3: ingen silo med plads/vare -> bliv ved Wait, én WARN
buildSwapWorld()
stations[2].getFreeCapacity = function() return 1000 end
local sv3, st3 = makeUnloadingVehicle("Fastrac Ingen", 523, 5000, 35000)
UnloadTask.update(st3, 16)
local mark3 = #logLines
UnloadTask.update(st3, ADLoadSwap.CHECK_INTERVAL_MS + 1)
UnloadTask.update(st3, ADLoadSwap.CHECK_INTERVAL_MS + 1)
check("S3 bliver ved Wait", st3.destinationID, 40)
check("S3 én advarsel", countLogsSince(mark3, "ingen silo"), 1)

-- S4: silo uden vejnet hjem springes over
buildSwapWorld()
FS25_AutoDrive.ADGraphManager.unreachable[21] = true
local sv4, st4 = makeUnloadingVehicle("Fastrac Vej", 524, 5000, 35000)
UnloadTask.update(st4, 16)
UnloadTask.update(st4, ADLoadSwap.CHECK_INTERVAL_MS + 1)
check("S4 unåelig silo -> bliv", st4.destinationID, 40)
FS25_AutoDrive.ADGraphManager.unreachable = {}

-- S5: traktor med kun én vare i listen bytter aldrig
buildSwapWorld()
local sv5, st5 = makeUnloadingVehicle("Fastrac Solo", 525, 5000, 35000)
sv5.ad.stateModule.selectedFillTypes = {STRAW}
UnloadTask.update(st5, 16)
UnloadTask.update(st5, ADLoadSwap.CHECK_INTERVAL_MS + 1)
check("S5 én vare -> bliver", st5.destinationID, 40)

Bemærk til S1: makeUnloadingVehicle giver vognen capacity = 40000 og listen {TMR, STRAW}; buildWaitWorld giver stalden TMR 10000/100000 → 90000 l fri ≥ 40000 → fits == true. Halmladen (markør 21) har halm i eget lager og 100000 l fri ≥ 35000 rest. TMR findes i fabrikkens læssestation (markør 20, output) så findSupplyPickup finder en kilde.

  • Step 2: Kør og se den fejle

Kør: cd ~/fs25-adsmartpickup && luajit tests/test_adSmartPickup.lua 2>&1 | grep -E "FAIL|FEJL|error" | head Forventet: cannot open FS25_ADSmartPickup/adLoadSwap.lua.

  • Step 3: Skriv modulet

FS25_ADSmartPickup/adLoadSwap.lua:

-- AD Load Swap
-- En traktor der venter ved et Wait-punkt med en vare målet er fuldt af, mens målet
-- MANGLER en anden vare i traktorens liste, låser forsyningen (alle venter med halm,
-- ingen henter TMR). Her afleveres resten i en silo der tager varen, så vognen er tom
-- og næste tur henter det målet mangler.

ADLoadSwap = {}

ADLoadSwap.LOG_PREFIX = "ADSmartPickup: "
-- Opslaget går over alle markører; ventende traktorer tjekker derfor kun så tit.
ADLoadSwap.CHECK_INTERVAL_MS = 5000

local warnedNoSilo = setmetatable({}, {__mode = "k"})

-- Er en anden vare i listen så tom i målet, at et FULDT læs passer, og findes den i en silo?
function ADLoadSwap.findMissingIngredient(adEnv, vehicle, restFillType)
    local ingredients = ADSmartPickup.getIngredientsFor(adEnv, vehicle)
    if ingredients == nil or #ingredients < 2 then
        return nil
    end
    local choice = ADSmartPickup.findSupplyPickup(adEnv, vehicle, ingredients)
    if choice == nil or choice.fillType == nil or choice.fillType == restFillType or choice.need.fits ~= true then
        return nil
    end
    return choice
end

-- Kun rene lager-siloer: stalde og fabrikker har egne regler, og målet selv er fuldt.
local function isSilo(station, homeStation)
    local placeable = station.owningPlaceable
    return placeable ~= nil and placeable.spec_silo ~= nil and placeable.getHusbandryFillLevel == nil
        and placeable.spec_productionPoint == nil and (homeStation == nil or placeable ~= homeStation.owningPlaceable)
end

local function acceptsRest(station, fillType, rest, farmId)
    if station.getIsFillTypeSupported ~= nil and not station:getIsFillTypeSupported(fillType) then
        return false
    end
    local free = station.getFreeCapacity ~= nil and station:getFreeCapacity(fillType, farmId) or 0
    return free >= rest
end

-- Nærmeste silo med plads til resten og vejnet til/fra; siloer der allerede har varen foretrækkes.
function ADLoadSwap.findDumpMarker(adEnv, vehicle, fillType, rest, homeWayPointId, isReachable)
    local farmId = ADSmartPickup.getFarmId(vehicle)
    local homeStation = ADSmartPickup.getUnloadStationAtWayPointId(adEnv, homeWayPointId, farmId)
    local x, _, z = getWorldTranslation(vehicle.rootNode)
    local candidates = {}
    for _, marker in pairs(adEnv.ADGraphManager:getMapMarkers()) do
        local station = ADSmartPickup.getUnloadStationAtWayPointId(adEnv, marker.id, farmId)
        if station ~= nil and isSilo(station, homeStation) and acceptsRest(station, fillType, rest, farmId) then
            local wayPoint = adEnv.ADGraphManager:getWayPointById(marker.id)
            local dx, dz = wayPoint.x - x, wayPoint.z - z
            table.insert(candidates, {
                marker = marker,
                hasFillType = ADSmartPickup.getOwnStorageLevel(station, {fillType}) > 0,
                distance = dx * dx + dz * dz,
            })
        end
    end
    table.sort(candidates, function(a, b)
        if a.hasFillType ~= b.hasFillType then
            return a.hasFillType
        end
        return a.distance < b.distance
    end)
    for _, candidate in ipairs(candidates) do
        if isReachable(candidate.marker.id) then
            return candidate.marker
        end
    end
    return nil
end

-- Kaldes fra Wait-logikken hvert tick; redirect(task, id) og release(vehicle) er dens egne.
-- true = traktoren er sendt til en silo (Wait-punktet er frigivet).
function ADLoadSwap.tryFromWait(adEnv, task, wait, dt, redirect, release, isReachable)
    wait.swapTimerMs = (wait.swapTimerMs or 0) + (dt or 0)
    if wait.swapTimerMs < ADLoadSwap.CHECK_INTERVAL_MS then
        return false
    end
    wait.swapTimerMs = 0
    local vehicle = task.vehicle
    local missing = ADLoadSwap.findMissingIngredient(adEnv, vehicle, wait.fillType)
    if missing == nil then
        return false
    end
    local marker = ADLoadSwap.findDumpMarker(adEnv, vehicle, wait.fillType, wait.rest, wait.homeDestinationID, isReachable)
    local restName = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(wait.fillType) or tostring(wait.fillType)
    local missingName = g_fillTypeManager ~= nil and g_fillTypeManager:getFillTypeNameByIndex(missing.fillType) or tostring(missing.fillType)
    if marker == nil then
        if not warnedNoSilo[vehicle] then
            warnedNoSilo[vehicle] = true
            Logging.warning(ADLoadSwap.LOG_PREFIX .. "'%s' venter med %.0f l %s, og målet mangler %s — ingen silo med plads og vejnet til/fra tager resten, så den bliver ved Wait-punktet", tostring(vehicle:getName()), wait.rest, tostring(restName), tostring(missingName))
        end
        return false
    end
    warnedNoSilo[vehicle] = nil
    release(vehicle)
    redirect(task, marker.id)
    Logging.info(ADLoadSwap.LOG_PREFIX .. "'%s' bytter læs: %.0f l %s tilbage i %s (markør %s), målet mangler %s", tostring(vehicle:getName()), wait.rest, tostring(restName), tostring(marker.name), tostring(marker.markerIndex or marker.id), tostring(missingName))
    return true
end

function ADLoadSwap.clearWarning(vehicle)
    warnedNoSilo[vehicle] = nil
end

adUnloadWait.lua — ændringer:

  1. updateWaiting(adEnv, task, wait) → updateWaiting(adEnv, task, wait, dt). Erstat blokken
    local free, capacity = ADSmartPickup.getTargetFreeCapacity(adEnv, vehicle, fillType)
    if free == nil or not ADSupplyPlanner.canUnloadRest(free, rest, capacity) then
        return
    end

med

    local free, capacity = ADSmartPickup.getTargetFreeCapacity(adEnv, vehicle, fillType)
    if free == nil or not ADSupplyPlanner.canUnloadRest(free, rest, capacity) then
        if ADLoadSwap ~= nil then
            local isReachable = function(markerId)
                return hasRouteBothWays(adEnv, vehicle, markerId, wait.homeDestinationID)
            end
            ADLoadSwap.tryFromWait(adEnv, task, wait, dt, redirect, release, isReachable)
        end
        return
    end

(hasRouteBothWays(adEnv, vehicle, waitId, homeId) tjekker allerede "til punktet" + "fra punktet hjem" — præcis det byttet også kræver.)

  1. ADUnloadWait.beforeUpdate(adEnv, task) → ADUnloadWait.beforeUpdate(adEnv, task, dt) og kald updateWaiting(adEnv, task, wait, dt). I install: pcall(ADUnloadWait.beforeUpdate, adEnv, self, dt).

  2. I release(vehicle): tilføj if ADLoadSwap ~= nil then ADLoadSwap.clearWarning(vehicle) end (ny vente-periode = ny chance for advarsel).

  3. modDesc.xml: adLoadSwap.lua indsættes FØR adUnloadWait.lua og EFTER adRunsController.lua.

  • Step 4: Kør alle tests

Kør: cd ~/fs25-adsmartpickup && for t in tests/test_*.lua; do luajit $t | tail -1; done Forventet: ALLE TESTS OK ×4. Fejler S1 fordi findSupplyPickup ikke finder TMR: tjek at fabrikkens læssestation (markør 20) i buildCowWorld stadig har [TMR] = 30000 og at målets TMR-fri plads ≥ MIN_FREE_LITERS.

  • Step 5: Commit
cd ~/fs25-adsmartpickup && git add -A FS25_ADSmartPickup tests && git commit -q -m "feat: læs-bytte fra Wait — rest afleveres i silo når målet mangler en anden vare"

Task 9: Udgivelse v1.9.0.0

Files:

  • Modify: FS25_ADSmartPickup/modDesc.xml (1.9.0.0, beskrivelse), README.md, hukommelse + Obsidian.

  • Step 1: modDesc + README

<version>1.9.0.0</version>; beskrivelse: tilføj Venter en traktor med en vare målet er fuldt af, mens målet mangler en anden vare på dens liste, afleveres resten i en silo og den manglende vare hentes. README: afsnit ## v1.9 — læs-bytte fra Wait med reglen (anden vare mangler et FULDT læs; kun rene siloer; fri plads ≥ rest; vejnet til/fra; foretrækker silo der allerede har varen; ellers bliver den ved Wait), loglinjerne bytter læs: … / ingen silo …, og at det kræver at traktoren har begge varer (run eller multi-valg).

  • Step 2: Test, byg, commit, push
cd ~/fs25-adsmartpickup && for t in tests/test_*.lua; do luajit $t | tail -1; done && cd FS25_ADSmartPickup && rm -f ../FS25_ADSmartPickup.zip && zip -qr ../FS25_ADSmartPickup.zip . && cp ../FS25_ADSmartPickup.zip ~/FS25-data/mods/ && cd .. && git add -A && git commit -q -m "feat: læs-bytte fra Wait (v1.9.0.0)" && TOKEN=$(grep -A8 -i "gitea" ~/credentials-vault.md | grep -oE '[0-9a-f]{40}' | head -1); git -c http.extraHeader="Authorization: token $TOKEN" push -q gitea HEAD && echo pushed
  • Step 3: In-game tjekliste
  1. Opsætning: run "Cow Feed" med Auto-varer (halm + TMR), 2 Fastrac, stalden fuld af halm, TMR lavt; en Fastrac med halm-rest venter ved Cow Wait.
  2. Inden for ~5 s efter ankomst til Wait: log 'Fastrac 2135 4WS' bytter læs: N l STRAW tilbage i 10 Silo Storage (markør 44), målet mangler FORAGE og traktoren kører til siloen og læsser af.
  3. Når vognen er tom, kører den til TMR-kilden (log -> TMR Load (FORAGE: kilde …)).
  4. Negativt: kobl siloens vejnet fra → ingen silo med plads og vejnet … én gang, traktoren bliver ved Wait.
  • Step 4: Hukommelse + Obsidian

Opdatér project_fs25_adsmartpickup.md og Obsidian-noten: v1.9 leveret, regler, in-game-status.